@xia-sc/dsh-cc-studio 0.4.1 → 0.8.0
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 +118 -295
- package/lib/agent.js +1402 -159
- package/lib/client.js +327 -8
- package/lib/index.js +647 -25
- 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.8.0";
|
|
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,13 +303,337 @@ 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
|
|
|
320
|
+
// ——————————————————————————————————————————————————————————————
|
|
321
|
+
// 人物关系图谱(0.8.0):分组文件 + 三维关系(方向 × 类型 × 强度)
|
|
322
|
+
//
|
|
323
|
+
// 与 lib/index.js **同构实现**(与 dataRoots / safeDraftFile 同模式):同样的常量、同样的纯函数、
|
|
324
|
+
// 同样的落盘规则(写主根、读回退旧根)。tests/relations.test.mjs 会逐字比对两半渲染输出 ——
|
|
325
|
+
// 改这里必须同步改 lib/index.js,反之亦然。
|
|
326
|
+
//
|
|
327
|
+
// 「卡属于哪个组」记在库条目 meta.groupId(单一真源),分组文件里刻意**不存成员列表**。
|
|
328
|
+
// 关系只活在插件侧;要让分享出去的卡自包含,用 cc_render_relations 渲染进 description 的固定标记区。
|
|
329
|
+
// ——————————————————————————————————————————————————————————————
|
|
330
|
+
const REL_TYPES = ["family","romance","mentor","friend","colleague","rival","enemy","debt","superior","other"];
|
|
331
|
+
const REL_TYPE_LABELS = { family:"亲属", romance:"恋慕", mentor:"师徒", friend:"挚友", colleague:"同门", rival:"竞争", enemy:"敌对", debt:"恩义", superior:"上下级", other:"其他" };
|
|
332
|
+
const REL_BEGIN = "<!-- cc-relations:begin";
|
|
333
|
+
const REL_END = "<!-- cc-relations:end -->";
|
|
334
|
+
|
|
335
|
+
function groupDirs(){ return dataRoots("cc-groups"); }
|
|
336
|
+
function groupDir(){ return groupDirs()[0]; }
|
|
337
|
+
function safeGroupId(id){ return String(id == null ? "" : id).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64); }
|
|
338
|
+
function groupPath(id){ return join(groupDir(), safeGroupId(id) + ".json"); }
|
|
339
|
+
function newGroupId(){ return Date.now() + "-" + Math.random().toString(36).slice(2, 6); }
|
|
340
|
+
async function ensureGroupDir(){ try{ await mkdir(groupDir(), { recursive: true }); }catch{} }
|
|
341
|
+
/** 与 lib/index.js 同构:那边叫 estimateTokensLite(宿主半没有 token 估算),这里复用本半已有的 estimateTokens。 */
|
|
342
|
+
function estimateTokensLite(text){ return estimateTokens(text); }
|
|
343
|
+
function relTypeOf(t){ const s = String(t == null ? "" : t); return REL_TYPES.indexOf(s) >= 0 ? s : "other"; }
|
|
344
|
+
function relLabel(edge){ return (edge && edge.label && String(edge.label).trim()) ? String(edge.label).trim() : (REL_TYPE_LABELS[(edge && edge.type) || "other"] || "其他"); }
|
|
345
|
+
function nameOfNode(nodes, id){ const n = (Array.isArray(nodes) ? nodes : []).find(x => x.id === id); return n ? (n.name || "未命名") : "(未知)"; }
|
|
346
|
+
|
|
347
|
+
/** 结构兜底。⚠️ 不丢弃端点不存在的边(那是用户数据),体检报 dangling-edge、渲染时自动跳过。 */
|
|
348
|
+
function normalizeGroup(g){
|
|
349
|
+
const src = (g && typeof g === "object") ? g : {};
|
|
350
|
+
const nodes = Array.isArray(src.nodes) ? src.nodes.filter(n => n && typeof n === "object" && n.id).map(n => ({
|
|
351
|
+
id: String(n.id),
|
|
352
|
+
kind: (n.kind === "card") ? "card" : "npc",
|
|
353
|
+
cardId: (typeof n.cardId === "string" && n.cardId) ? n.cardId : null,
|
|
354
|
+
name: String(n.name || "").slice(0, 60),
|
|
355
|
+
aliases: Array.isArray(n.aliases) ? n.aliases.map(String).map(s => s.trim()).filter(Boolean).slice(0, 8) : [],
|
|
356
|
+
orphanOf: (typeof n.orphanOf === "string" && n.orphanOf) ? n.orphanOf : null,
|
|
357
|
+
})) : [];
|
|
358
|
+
const edges = Array.isArray(src.edges) ? src.edges.filter(e => e && typeof e === "object" && e.id).map(e => ({
|
|
359
|
+
id: String(e.id),
|
|
360
|
+
from: String(e.from || ""),
|
|
361
|
+
to: String(e.to || ""),
|
|
362
|
+
type: relTypeOf(e.type),
|
|
363
|
+
label: String(e.label || "").slice(0, 24),
|
|
364
|
+
strength: Math.max(1, Math.min(5, Math.round(Number(e.strength) || 3))),
|
|
365
|
+
direction: (e.direction === "mutual") ? "mutual" : "one-way",
|
|
366
|
+
note: String(e.note || "").slice(0, 200),
|
|
367
|
+
updatedAt: Number(e.updatedAt) || nowSec(),
|
|
368
|
+
})) : [];
|
|
369
|
+
return {
|
|
370
|
+
id: safeGroupId(src.id),
|
|
371
|
+
name: String(src.name || "").slice(0, 60) || "未命名分组",
|
|
372
|
+
note: String(src.note || "").slice(0, 200),
|
|
373
|
+
nodes, edges,
|
|
374
|
+
nextNodeSeq: Math.max(1, Math.round(Number(src.nextNodeSeq) || (nodes.length + 1))),
|
|
375
|
+
nextEdgeSeq: Math.max(1, Math.round(Number(src.nextEdgeSeq) || (edges.length + 1))),
|
|
376
|
+
rev: Math.max(0, Math.round(Number(src.rev) || 0)),
|
|
377
|
+
updatedAt: Number(src.updatedAt) || nowSec(),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function dupNodeName(g, name, aliases, exceptId){
|
|
381
|
+
const wanted = [String(name || "").trim()].concat(Array.isArray(aliases) ? aliases.map(s => String(s).trim()) : [])
|
|
382
|
+
.filter(Boolean).map(s => s.toLowerCase());
|
|
383
|
+
if(!wanted.length) return false;
|
|
384
|
+
return g.nodes.some(n => {
|
|
385
|
+
if(exceptId && n.id === exceptId) return false;
|
|
386
|
+
const have = [n.name].concat(Array.isArray(n.aliases) ? n.aliases : []).filter(Boolean).map(s => String(s).toLowerCase());
|
|
387
|
+
return have.some(x => wanted.indexOf(x) >= 0);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function validateGroupOp(g, op){
|
|
391
|
+
if(!op || typeof op !== "object") return "op 必须是对象";
|
|
392
|
+
const kind = String(op.op || "");
|
|
393
|
+
const has = (id) => g.nodes.some(n => n.id === id);
|
|
394
|
+
if(kind === "addNode"){
|
|
395
|
+
if(!String(op.name || "").trim()) return "addNode 需要 name";
|
|
396
|
+
if(dupNodeName(g, op.name, op.aliases, null)) return "节点名或别名与组内既有节点重复:" + String(op.name).trim();
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if(kind === "updateNode"){
|
|
400
|
+
const cur = g.nodes.find(n => n.id === String(op.nodeId || ""));
|
|
401
|
+
if(!cur) return "节点不存在:" + op.nodeId;
|
|
402
|
+
if(op.name !== undefined || op.aliases !== undefined){
|
|
403
|
+
const name = (op.name !== undefined) ? op.name : cur.name;
|
|
404
|
+
const aliases = (op.aliases !== undefined) ? op.aliases : cur.aliases;
|
|
405
|
+
if(dupNodeName(g, name, aliases, cur.id)) return "节点名或别名与组内既有节点重复:" + String(name).trim();
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
if(kind === "removeNode") return has(String(op.nodeId || "")) ? null : "节点不存在:" + op.nodeId;
|
|
410
|
+
if(kind === "addEdge"){
|
|
411
|
+
if(!has(String(op.from || ""))) return "from 节点不存在:" + op.from;
|
|
412
|
+
if(!has(String(op.to || ""))) return "to 节点不存在:" + op.to;
|
|
413
|
+
if(String(op.from) === String(op.to)) return "不能给自己建关系(from 与 to 相同)";
|
|
414
|
+
const st = Number(op.strength === undefined ? 3 : op.strength);
|
|
415
|
+
if(!(st >= 1 && st <= 5)) return "strength 必须在 1..5";
|
|
416
|
+
if(op.type !== undefined && REL_TYPES.indexOf(String(op.type)) < 0) return "type 不在枚举内:" + op.type;
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
if(kind === "updateEdge"){
|
|
420
|
+
const e = g.edges.find(x => x.id === String(op.edgeId || ""));
|
|
421
|
+
if(!e) return "边不存在:" + op.edgeId;
|
|
422
|
+
if(op.strength !== undefined){ const st = Number(op.strength); if(!(st >= 1 && st <= 5)) return "strength 必须在 1..5"; }
|
|
423
|
+
if(op.type !== undefined && REL_TYPES.indexOf(String(op.type)) < 0) return "type 不在枚举内:" + op.type;
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if(kind === "removeEdge") return g.edges.some(e => e.id === String(op.edgeId || "")) ? null : "边不存在:" + op.edgeId;
|
|
427
|
+
return "未知 op:" + kind;
|
|
428
|
+
}
|
|
429
|
+
function applyGroupOps(group, ops){
|
|
430
|
+
const g = normalizeGroup(JSON.parse(JSON.stringify(group || {})));
|
|
431
|
+
const applied = [], rejected = [];
|
|
432
|
+
(Array.isArray(ops) ? ops : []).forEach(op => {
|
|
433
|
+
const err = validateGroupOp(g, op);
|
|
434
|
+
if(err){ rejected.push({ op, error: err }); return; }
|
|
435
|
+
const kind = String(op.op);
|
|
436
|
+
if(kind === "addNode"){
|
|
437
|
+
const id = "n" + (g.nextNodeSeq++);
|
|
438
|
+
g.nodes.push({
|
|
439
|
+
id, kind: (op.kind === "card") ? "card" : "npc",
|
|
440
|
+
cardId: (op.kind === "card" && op.cardId) ? String(op.cardId) : null,
|
|
441
|
+
name: String(op.name).trim().slice(0, 60),
|
|
442
|
+
aliases: (Array.isArray(op.aliases) ? op.aliases : []).map(String).map(s => s.trim()).filter(Boolean).slice(0, 8),
|
|
443
|
+
orphanOf: null,
|
|
444
|
+
});
|
|
445
|
+
applied.push({ op: kind, nodeId: id });
|
|
446
|
+
} else if(kind === "updateNode"){
|
|
447
|
+
const n = g.nodes.find(x => x.id === String(op.nodeId));
|
|
448
|
+
if(op.name !== undefined) n.name = String(op.name).trim().slice(0, 60);
|
|
449
|
+
if(op.aliases !== undefined) n.aliases = (Array.isArray(op.aliases) ? op.aliases : []).map(String).map(s => s.trim()).filter(Boolean).slice(0, 8);
|
|
450
|
+
applied.push({ op: kind, nodeId: n.id });
|
|
451
|
+
} else if(kind === "removeNode"){
|
|
452
|
+
const nid = String(op.nodeId);
|
|
453
|
+
g.nodes = g.nodes.filter(n => n.id !== nid);
|
|
454
|
+
if(op.removeEdges !== false) g.edges = g.edges.filter(e => e.from !== nid && e.to !== nid);
|
|
455
|
+
applied.push({ op: kind, nodeId: nid, edgesRemoved: (op.removeEdges !== false) });
|
|
456
|
+
} else if(kind === "addEdge"){
|
|
457
|
+
const id = "e" + (g.nextEdgeSeq++);
|
|
458
|
+
g.edges.push({
|
|
459
|
+
id, from: String(op.from), to: String(op.to), type: relTypeOf(op.type),
|
|
460
|
+
label: String(op.label || "").slice(0, 24),
|
|
461
|
+
strength: Math.max(1, Math.min(5, Math.round(Number(op.strength === undefined ? 3 : op.strength)))),
|
|
462
|
+
direction: (op.direction === "mutual") ? "mutual" : "one-way",
|
|
463
|
+
note: String(op.note || "").slice(0, 200), updatedAt: nowSec(),
|
|
464
|
+
});
|
|
465
|
+
applied.push({ op: kind, edgeId: id });
|
|
466
|
+
} else if(kind === "updateEdge"){
|
|
467
|
+
const e = g.edges.find(x => x.id === String(op.edgeId));
|
|
468
|
+
if(op.type !== undefined) e.type = relTypeOf(op.type);
|
|
469
|
+
if(op.label !== undefined) e.label = String(op.label).slice(0, 24);
|
|
470
|
+
if(op.strength !== undefined) e.strength = Math.max(1, Math.min(5, Math.round(Number(op.strength))));
|
|
471
|
+
if(op.direction !== undefined) e.direction = (op.direction === "mutual") ? "mutual" : "one-way";
|
|
472
|
+
if(op.note !== undefined) e.note = String(op.note).slice(0, 200);
|
|
473
|
+
e.updatedAt = nowSec();
|
|
474
|
+
applied.push({ op: kind, edgeId: e.id });
|
|
475
|
+
} else if(kind === "removeEdge"){
|
|
476
|
+
const eid = String(op.edgeId);
|
|
477
|
+
g.edges = g.edges.filter(e => e.id !== eid);
|
|
478
|
+
applied.push({ op: kind, edgeId: eid });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
g.updatedAt = nowSec();
|
|
482
|
+
return { group: g, applied, rejected };
|
|
483
|
+
}
|
|
484
|
+
function renderRelationsSection(myNodeId, nodes, edges, opts){
|
|
485
|
+
const o = opts || {};
|
|
486
|
+
const byId = new Map((Array.isArray(nodes) ? nodes : []).map(n => [n.id, n]));
|
|
487
|
+
const nameOf = (id) => { const n = byId.get(id); return n ? (n.name || "未命名") : "(未知)"; };
|
|
488
|
+
const all = (Array.isArray(edges) ? edges : []).filter(e => byId.has(e.from) && byId.has(e.to));
|
|
489
|
+
const outgoing = all.filter(e => e.from === myNodeId);
|
|
490
|
+
const incoming = all.filter(e => e.to === myNodeId && e.direction !== "mutual");
|
|
491
|
+
const order = (t) => { const i = REL_TYPES.indexOf(t); return i < 0 ? REL_TYPES.length : i; };
|
|
492
|
+
const fmt = (e, otherId) => "- " + nameOf(otherId) + "(" + relLabel(e) + "|" + e.strength + "/5" + (e.direction === "mutual" ? ",互向" : "") + ")" + (e.note ? ":" + e.note : "");
|
|
493
|
+
const byTypeThenStrength = (other) => (a, b) => (order(a.type) - order(b.type)) || (b.strength - a.strength) || nameOf(a[other]).localeCompare(nameOf(b[other]));
|
|
494
|
+
const lines = ["【关系】"];
|
|
495
|
+
if(!outgoing.length) lines.push("- (暂无)");
|
|
496
|
+
else outgoing.slice().sort(byTypeThenStrength("to")).forEach(e => lines.push(fmt(e, e.to)));
|
|
497
|
+
if(o.includeIncoming && incoming.length){
|
|
498
|
+
lines.push("", "【他们眼中的我】");
|
|
499
|
+
incoming.slice().sort(byTypeThenStrength("from")).forEach(e => lines.push(fmt(e, e.from)));
|
|
500
|
+
}
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
function spliceRelations(description, section, meta){
|
|
504
|
+
const desc = String(description || "");
|
|
505
|
+
const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
|
|
506
|
+
const block = section ? (REL_BEGIN + (meta ? " " + meta : "") + " -->\n" + section + "\n" + REL_END) : "";
|
|
507
|
+
if(i >= 0 && j > i){
|
|
508
|
+
const before = desc.slice(0, i).replace(/\s*$/, "");
|
|
509
|
+
const after = desc.slice(j + REL_END.length);
|
|
510
|
+
const head = block ? (before + (before ? "\n\n" : "") + block) : before;
|
|
511
|
+
return (head + after).replace(/\n{3,}/g, "\n\n").trim();
|
|
512
|
+
}
|
|
513
|
+
if(!block) return desc;
|
|
514
|
+
const head = desc.replace(/\s*$/, "");
|
|
515
|
+
return (head + (head ? "\n\n" : "") + block).trim();
|
|
516
|
+
}
|
|
517
|
+
function checkGroup(group, members, cards){
|
|
518
|
+
const g = normalizeGroup(group);
|
|
519
|
+
const issues = [];
|
|
520
|
+
const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
|
|
521
|
+
const nodeIds = new Set(g.nodes.map(n => n.id));
|
|
522
|
+
const byId = new Map((Array.isArray(cards) ? cards : []).map(c => [c.id, c]));
|
|
523
|
+
const dangling = g.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
|
|
524
|
+
if(dangling.length) add("error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
|
|
525
|
+
const orphans = g.nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
|
|
526
|
+
if(orphans.length) add("warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
|
|
527
|
+
const seen = new Map();
|
|
528
|
+
g.nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
|
|
529
|
+
const k = String(x || "").toLowerCase();
|
|
530
|
+
if(!k) return;
|
|
531
|
+
if(!seen.has(k)) seen.set(k, []);
|
|
532
|
+
if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
|
|
533
|
+
}));
|
|
534
|
+
const dup = [...seen.entries()].filter(p => p[1].length > 1);
|
|
535
|
+
if(dup.length) add("error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
|
|
536
|
+
const unknown = g.edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
|
|
537
|
+
if(unknown.length) add("warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
|
|
538
|
+
const memberCards = (Array.isArray(members) ? members : []).map(m => byId.get(m)).filter(Boolean);
|
|
539
|
+
const unrendered = [], stale = [];
|
|
540
|
+
memberCards.forEach(c => {
|
|
541
|
+
const desc = String(c.description || "");
|
|
542
|
+
const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
|
|
543
|
+
if(i < 0 || j < 0){ unrendered.push(c.id); return; }
|
|
544
|
+
const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
|
|
545
|
+
if(!m || Number(m[1]) !== g.rev) stale.push(c.id);
|
|
546
|
+
});
|
|
547
|
+
if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
|
|
548
|
+
if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
|
|
549
|
+
const constTokens = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
|
|
550
|
+
const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
|
|
551
|
+
if(memberCards.length > 1 && constTokens > minBudget * 0.8){
|
|
552
|
+
add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻条目合计约 " + constTokens + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
|
|
553
|
+
}
|
|
554
|
+
return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length, constantTokens: constTokens, minBudget } };
|
|
555
|
+
}
|
|
556
|
+
async function readGroupRaw(id){
|
|
557
|
+
const file = safeGroupId(id) + ".json";
|
|
558
|
+
for(const dir of groupDirs()){
|
|
559
|
+
try{
|
|
560
|
+
const raw = await readFile(join(dir, file), "utf8");
|
|
561
|
+
const obj = JSON.parse(raw);
|
|
562
|
+
if(obj && typeof obj === "object") return { raw, obj };
|
|
563
|
+
}catch{}
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
async function listGroups(){
|
|
568
|
+
await ensureGroupDir();
|
|
569
|
+
const seen = new Map();
|
|
570
|
+
for(const dir of groupDirs()){
|
|
571
|
+
let files = [];
|
|
572
|
+
try{ files = await readdir(dir); }catch{ continue; }
|
|
573
|
+
for(const f of files){
|
|
574
|
+
if(!f.endsWith(".json")) continue;
|
|
575
|
+
const id = f.replace(/\.json$/, "");
|
|
576
|
+
if(seen.has(id)) continue;
|
|
577
|
+
try{ seen.set(id, normalizeGroup(JSON.parse(await readFile(join(dir, f), "utf8")))); }catch{}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return [...seen.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
581
|
+
}
|
|
582
|
+
async function writeGroup(group){
|
|
583
|
+
await ensureGroupDir();
|
|
584
|
+
const cur = normalizeGroup(group);
|
|
585
|
+
let rev = cur.rev || 0;
|
|
586
|
+
const prev = await readGroupRaw(cur.id);
|
|
587
|
+
if(prev && prev.obj && typeof prev.obj.rev === "number") rev = prev.obj.rev + 1;
|
|
588
|
+
else if(!prev) rev = 1;
|
|
589
|
+
cur.rev = rev;
|
|
590
|
+
cur.updatedAt = nowSec();
|
|
591
|
+
await writeFile(groupPath(cur.id), JSON.stringify(cur, null, 2), "utf8");
|
|
592
|
+
return cur;
|
|
593
|
+
}
|
|
594
|
+
async function groupMemberIds(groupId){
|
|
595
|
+
const gid = safeGroupId(groupId);
|
|
596
|
+
const entries = await listLibraryEntries();
|
|
597
|
+
return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
|
|
598
|
+
}
|
|
599
|
+
/** 体检 / 渲染需要的卡片摘要(卡不在库里就跳过)。 */
|
|
600
|
+
async function libraryCardsFor(ids){
|
|
601
|
+
const out = [];
|
|
602
|
+
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
603
|
+
try{
|
|
604
|
+
const obj = JSON.parse((await readLibraryRaw(id)).raw);
|
|
605
|
+
const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
|
|
606
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
607
|
+
out.push({
|
|
608
|
+
id: String(id),
|
|
609
|
+
name: String(d.name || ""),
|
|
610
|
+
description: String(d.description || ""),
|
|
611
|
+
constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
|
|
612
|
+
tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
|
|
613
|
+
});
|
|
614
|
+
}catch{}
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
/** 解析「这次操作哪个分组」:显式 groupId 优先,否则用当前卡(来源 id → 库 meta.groupId)所属组。 */
|
|
619
|
+
async function resolveGroupFor(ctx, exec, explicitGroupId){
|
|
620
|
+
const k = keyFor(ctx, exec);
|
|
621
|
+
const origin = readOriginSync(k);
|
|
622
|
+
const cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
623
|
+
const entries = await listLibraryEntries();
|
|
624
|
+
const mine = cardId ? entries.find(e => e.id === cardId) : null;
|
|
625
|
+
const groupId = explicitGroupId ? safeGroupId(explicitGroupId) : ((mine && mine.groupId) ? String(mine.groupId) : "");
|
|
626
|
+
const memberCount = {};
|
|
627
|
+
entries.forEach(e => { const gid = String(e.groupId || ""); if(gid) memberCount[gid] = (memberCount[gid] || 0) + 1; });
|
|
628
|
+
const groups = (await listGroups()).map(g => ({ id: g.id, name: g.name, nodeCount: g.nodes.length, edgeCount: g.edges.length, memberCount: memberCount[g.id] || 0 }));
|
|
629
|
+
if(!groupId) return { ok: false, cardId: cardId || null, groupId: "", groups };
|
|
630
|
+
const found = await readGroupRaw(groupId);
|
|
631
|
+
if(!found) return { ok: false, cardId: cardId || null, groupId, groups, error: "分组不存在:" + groupId };
|
|
632
|
+
const g = normalizeGroup(found.obj);
|
|
633
|
+
const myNode = cardId ? (g.nodes.find(n => n.kind === "card" && n.cardId === cardId) || null) : null;
|
|
634
|
+
return { ok: true, cardId, groupId, group: g, myNode, groups };
|
|
635
|
+
}
|
|
636
|
+
|
|
146
637
|
// —— 已存角色库:<主根>/cc-library/<id>.json(读盘回退 0.3.6 之前的 homedir()/.dsh)——
|
|
147
638
|
function libDirs(){ return dataRoots("cc-library"); }
|
|
148
639
|
function libDir(){ return libDirs()[0]; }
|
|
@@ -165,11 +656,12 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
|
165
656
|
const data=draft&&draft.data?draft.data:{};
|
|
166
657
|
const now=nowSec();
|
|
167
658
|
const loreCount=Array.isArray(data.character_book?.entries)?data.character_book.entries.length:0;
|
|
168
|
-
const
|
|
659
|
+
const allTags=Array.isArray(data.tags)?data.tags.map(String):[];
|
|
660
|
+
const tags=allTags.slice(0,6);
|
|
169
661
|
return {
|
|
170
662
|
id:String(id),
|
|
171
663
|
name:(data.name && String(data.name).trim()) || (existingMeta && existingMeta.name) || "未命名角色",
|
|
172
|
-
loreCount, tags,
|
|
664
|
+
loreCount, tags, tagsTotal: allTags.length,
|
|
173
665
|
descriptionSnippet:String(data.description||"").slice(0,80),
|
|
174
666
|
createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
|
|
175
667
|
updatedAt: now,
|
|
@@ -207,10 +699,15 @@ async function saveLibraryEntry(draft, opts){
|
|
|
207
699
|
}
|
|
208
700
|
if(!id) id=String(Date.now())+"-"+Math.random().toString(36).slice(2,6);
|
|
209
701
|
const meta=makeLibraryEntryMeta(draft, existing?existing.meta:null, id);
|
|
210
|
-
|
|
702
|
+
// 改名 = 单一事实来源:meta.name 与 draft.data.name 一起改(否则「列表名 / 导出名」两套,用户改名看不出效果)
|
|
703
|
+
if(opts && typeof opts.name==="string" && opts.name.trim()){
|
|
704
|
+
const n=String(opts.name).trim().slice(0,60);
|
|
705
|
+
meta.name=n;
|
|
706
|
+
draft.data.name=n;
|
|
707
|
+
}
|
|
211
708
|
const payload={id,meta,draft};
|
|
212
709
|
await writeFile(join(dir,id+".json"), JSON.stringify(payload,null,2), "utf8");
|
|
213
|
-
return {id,meta,draft};
|
|
710
|
+
return {id,meta,draft,action:existing?"updated":"created"};
|
|
214
711
|
}
|
|
215
712
|
async function loadLibraryEntry(id){
|
|
216
713
|
await ensureLibDir();
|
|
@@ -238,60 +735,250 @@ async function renameLibraryEntry(id,newName){
|
|
|
238
735
|
if(!name) throw new Error("name 不能为空");
|
|
239
736
|
obj.meta.name=name;
|
|
240
737
|
obj.meta.updatedAt=nowSec();
|
|
738
|
+
// 单一事实来源(0.5.0):列表名与卡内 data.name 一起改 —— 此前只改 meta,导出后仍是旧名
|
|
739
|
+
if(obj.draft && obj.draft.data) obj.draft.data.name=name;
|
|
241
740
|
await writeFile(found.path, JSON.stringify(obj,null,2), "utf8");
|
|
242
741
|
return obj.meta;
|
|
243
742
|
}
|
|
244
743
|
|
|
245
744
|
function out(schema){ return { schema, render: (args, result) => [{ type:"text", text: JSON.stringify(result, null, 2) }] }; }
|
|
246
745
|
|
|
247
|
-
// ——
|
|
746
|
+
// —— 强制工作流状态计算(0.5.0 重写)——
|
|
747
|
+
// 两处结构性修正,都来自 LLM 侧实测反馈:
|
|
748
|
+
// ① **每一项都是三态**(missing / partial / ok),不再有 `characterOk` 与 `characterPartial` 同时为 true
|
|
749
|
+
// 的自相矛盾组合(模型无法据此决策,只能忽略);
|
|
750
|
+
// ② **lore 的「完成」要求至少 1 条非自动条目**:否则 autoLorebook 一次生成 5 条就顶替了第 4 步的共创,
|
|
751
|
+
// 而 `cc_add_lorebook_entries` 的契约写着「必调」—— 两条相反指令同时下发正是实测到的卡壳点。
|
|
752
|
+
const WORLD_DIMS = ["timeline","factions","geo","power","daily"];
|
|
753
|
+
const DIM_NAMES = { timeline:"年表", factions:"势力", geo:"地理", power:"力量体系", daily:"日常" };
|
|
754
|
+
// 英文 key 与 0.4.x 生成过的完全一致(老卡里就是这个写法),避免升级后触发词看起来「悄悄变了」
|
|
755
|
+
const DIM_EN = { timeline:"timeline", factions:"faction", geo:"geography", power:"power", daily:"daily" };
|
|
756
|
+
// 旧数据没有 cc_auto 标记,靠「名字是维度名」兜底;但**必须同时看内容**,
|
|
757
|
+
// 否则用户手工建的、恰好叫「日常」的条目会被下一次 cc_patch_world 静默删掉(0.5.0 修)。
|
|
758
|
+
const AUTO_NAMES = new Set(["年表","势力","地理","力量体系","日常"]);
|
|
759
|
+
function isAutoEntry(e){
|
|
760
|
+
return !!(e && (e.cc_auto === true || (e.extensions && e.extensions.cc_auto)));
|
|
761
|
+
}
|
|
762
|
+
function looksLikeLegacyAuto(e){
|
|
763
|
+
if(!e || !AUTO_NAMES.has(String(e.name||""))) return false;
|
|
764
|
+
return /^\s*@@/.test(String(e.content||"")) || Array.isArray(e.keys) && e.keys.some(k => ["timeline","faction","geography","power","daily"].includes(String(k)));
|
|
765
|
+
}
|
|
248
766
|
function workflowStatus(draft){
|
|
249
767
|
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
|
-
|
|
768
|
+
const w = (d.extensions && d.extensions.cc_world) ? d.extensions.cc_world : {};
|
|
769
|
+
const entries = (d.character_book && Array.isArray(d.character_book.entries)) ? d.character_book.entries : [];
|
|
770
|
+
const loreCount = entries.length;
|
|
771
|
+
const manualLoreCount = entries.filter(e => !isAutoEntry(e)).length;
|
|
772
|
+
const worldFilled = WORLD_DIMS.filter(k => typeof w[k]==="string" && String(w[k]).trim().length>=6).length;
|
|
773
|
+
|
|
774
|
+
const nameOk = !!String(d.name||"").trim();
|
|
775
|
+
const descOk = !!String(d.description||"").trim();
|
|
776
|
+
const characterState = (nameOk && descOk && String(d.personality||"").trim() && String(d.scenario||"").trim())
|
|
777
|
+
? "ok" : ((nameOk && descOk) ? "partial" : "missing");
|
|
778
|
+
const worldState = worldFilled >= 3 ? "ok" : (worldFilled > 0 ? "partial" : "missing");
|
|
779
|
+
const loreState = (loreCount >= 5 && manualLoreCount >= 1) ? "ok" : (loreCount > 0 ? "partial" : "missing");
|
|
780
|
+
const gFirst = !!String(d.first_mes||"").trim();
|
|
781
|
+
const gAlt = Array.isArray(d.alternate_greetings) ? d.alternate_greetings.length : 0;
|
|
782
|
+
const gGroup = Array.isArray(d.group_only_greetings) ? d.group_only_greetings.length : 0;
|
|
783
|
+
const greetingsState = (gFirst && gAlt>=2 && gGroup>=1) ? "ok" : ((gFirst || gGroup>=1) ? "partial" : "missing");
|
|
784
|
+
|
|
785
|
+
let step = 6; let next = "";
|
|
786
|
+
if(characterState !== "ok"){
|
|
787
|
+
step = 2; next = "下一步必须 cc_patch_character:先与用户讨论角色定位(气质/关系张力等,1-2 问)再补齐 name/description/personality/scenario,严禁直接推断填满";
|
|
788
|
+
} else if(worldState !== "ok"){
|
|
789
|
+
step = 3; next = "角色基础已齐,下一步必须 cc_patch_world:先与用户讨论世界观侧重(哪几维最想展开/有无私设)再补 5 维(≥3 项),**并为每一维自报触发词 loreKeys**(真实对话里用户会说的实体名,如 {geo:[\"旧校舍\",\"广播室\"]})——不给的话自动条目只能用维度名当 key,永远命中不了";
|
|
790
|
+
} else if(loreState !== "ok"){
|
|
791
|
+
step = 4;
|
|
792
|
+
next = manualLoreCount === 0
|
|
793
|
+
? "五维已齐,autoLorebook 已自动生成 "+loreCount+" 条基础条目,但它们以维度名当触发词、且不满足共创要求:下一步必须 cc_add_lorebook_entries,先与用户讨论触发词/常驻偏好,至少再补 1 条(总≥5 条、至少 1 constant、content 头部写 @@decorator)"
|
|
794
|
+
: "五维已齐,下一步必须 cc_add_lorebook_entries:与用户讨论触发词/常驻偏好后补到≥5 条(年表/势力/地理/力量/日常各 1,至少 1 constant,@@decorator),当前 "+loreCount+" 条(其中共创 "+manualLoreCount+" 条)";
|
|
795
|
+
} else if(greetingsState !== "ok"){
|
|
796
|
+
step = 5; next = "世界书已齐("+loreCount+" 条,其中共创 "+manualLoreCount+" 条),下一步必须 cc_patch_greetings:先问用户偏好的开场场景/关系阶段/群聊与私聊区分,再补问候语(first_mes + alternate≥2 + group≥1)";
|
|
269
797
|
} else {
|
|
270
|
-
step = 6; next = "
|
|
798
|
+
step = 6; next = "结构已齐,下一步必须 cc_validate 校验,valid=true 才可收尾;通过后提醒用户在工坊左下角「校验导出」入口打开导出页(JSON / PNG / CHARX),或点「★ 保存当前」落盘";
|
|
271
799
|
}
|
|
272
|
-
const
|
|
273
|
-
return {
|
|
800
|
+
const state = { character: characterState, world: worldState, lore: loreState, greetings: greetingsState };
|
|
801
|
+
return {
|
|
802
|
+
step,
|
|
803
|
+
readyToValidate: step === 6,
|
|
804
|
+
state,
|
|
805
|
+
worldFilled, loreCount, manualLoreCount,
|
|
806
|
+
next,
|
|
807
|
+
// 0.6.0(R1/R2):`hint` 只报**进度**,不再内嵌 `next` 全文 ——
|
|
808
|
+
// 旧版 `hint = 进度 + " → " + next` 让同一句话在一份返回里出现三遍(hint 内嵌 next、message 又等于 hint)。
|
|
809
|
+
// 终态(校验通过)也走同一个函数,所以 `hint` 不会再说「下一步必须 cc_validate」(那是第一轮 P0-3 的半修残留)。
|
|
810
|
+
hint: progressHint(step, state, worldFilled, loreCount, manualLoreCount, false),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** 一行进度摘要(**不含 next** —— 下一步是 workflow.next 的职责,别再复制一份)。 */
|
|
814
|
+
function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done){
|
|
815
|
+
const mark = s => s==="ok" ? "✓" : (s==="partial" ? "◐" : "✗");
|
|
816
|
+
return "工作流 " + step + "/" + (done ? 7 : 6)
|
|
817
|
+
+ " " + mark(state.character) + "角色"
|
|
818
|
+
+ " " + mark(state.world) + "五维(" + worldFilled + "/5)"
|
|
819
|
+
+ " " + mark(state.lore) + "世界书(" + loreCount + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
|
|
820
|
+
+ " " + mark(state.greetings) + "问候语"
|
|
821
|
+
+ (done ? " · 已完成,可导出/保存" : "");
|
|
822
|
+
}
|
|
823
|
+
// 粗略 token 估算:CJK 一字≈1 token,其余按 4 字符≈1 token(用于 validate 的预算告警与 lint/preview 报告)
|
|
824
|
+
function estimateTokens(text){
|
|
825
|
+
const s = String(text||"");
|
|
826
|
+
let cjk = 0;
|
|
827
|
+
for(const ch of s){ if(ch.charCodeAt(0) > 0x2E7F) cjk++; }
|
|
828
|
+
const rest = s.length - cjk;
|
|
829
|
+
return Math.ceil(cjk + rest/4);
|
|
830
|
+
}
|
|
831
|
+
function lorebookTokens(entries){
|
|
832
|
+
return (Array.isArray(entries)? entries : []).reduce((n,e) => n + estimateTokens(e && e.content), 0);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* 最坏情况单次触发量(0.7.0,N2)。
|
|
836
|
+
*
|
|
837
|
+
* 为什么不能只看总量:`token_budget` 管的是**一轮对话**里世界书能塞多少,而真正的坑是
|
|
838
|
+
* 「用户随便一句话被好几个条目同时命中」—— 第四轮实测:总量 2272 / 预算 1200 看着只是超一点,
|
|
839
|
+
* 但一句「剑冢到底是什么地方」单独就吃掉 **1139 token**(势力 + 地理 + 力量体系(constant) + 剑冢(constant))。
|
|
840
|
+
* 更糟的是它太好「修好」了:把 token_budget 调大,lint 立刻只剩一条 warning,可单次爆炸一点没变。
|
|
841
|
+
*
|
|
842
|
+
* 算法:把**共享同一个触发词的条目**并成一组(传递闭包 —— A/B 共享 x、B/C 共享 y,那么一句话
|
|
843
|
+
* 可以把三条全带出来),再叠加**每轮必插的 constant**,取最大的那组作为最坏情况。
|
|
844
|
+
* 单字符 key 不参与共现("a" 这种会把整本书并成一组,报告全是噪音)。
|
|
845
|
+
*/
|
|
846
|
+
function worstCaseTrigger(entries){
|
|
847
|
+
const list = (Array.isArray(entries) ? entries : []).filter(e => e && e.enabled !== false);
|
|
848
|
+
const constantEntries = list.filter(e => e.constant);
|
|
849
|
+
const constantTokens = constantEntries.reduce((n, e) => n + estimateTokens(e && e.content), 0);
|
|
850
|
+
const varEntries = list.filter(e => !e.constant);
|
|
851
|
+
const parent = new Map();
|
|
852
|
+
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; };
|
|
853
|
+
varEntries.forEach((e, i) => parent.set(i, i));
|
|
854
|
+
const byKey = new Map();
|
|
855
|
+
varEntries.forEach((e, i) => {
|
|
856
|
+
(Array.isArray(e.keys) ? e.keys : []).forEach(k => {
|
|
857
|
+
const kk = String(k).trim().toLowerCase();
|
|
858
|
+
if(kk.length < 2) return;
|
|
859
|
+
if(!byKey.has(kk)) byKey.set(kk, []);
|
|
860
|
+
byKey.get(kk).push(i);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
byKey.forEach(idxList => { for(let i = 1; i < idxList.length; i++) parent.set(find(idxList[i]), find(idxList[0])); });
|
|
864
|
+
const groups = new Map();
|
|
865
|
+
varEntries.forEach((e, i) => {
|
|
866
|
+
const root = find(i);
|
|
867
|
+
if(!groups.has(root)) groups.set(root, { tokens: 0, names: [] });
|
|
868
|
+
const g = groups.get(root);
|
|
869
|
+
g.tokens += estimateTokens(e && e.content);
|
|
870
|
+
g.names.push(String((e && e.name) || ""));
|
|
871
|
+
});
|
|
872
|
+
let best = { tokens: constantTokens, names: [] };
|
|
873
|
+
groups.forEach(g => {
|
|
874
|
+
if(g.tokens + constantTokens > best.tokens) best = { tokens: g.tokens + constantTokens, names: g.names.slice() };
|
|
875
|
+
});
|
|
876
|
+
// 单条自己就最重的情况已含在上面(没有共享 key 的条目自成一组),这里只把常驻条目的名字补进名单。
|
|
877
|
+
const names = best.names.concat(constantEntries.map(e => String((e && e.name) || "")));
|
|
878
|
+
return { tokens: best.tokens, constantTokens, entries: names };
|
|
274
879
|
}
|
|
275
880
|
|
|
881
|
+
|
|
276
882
|
function apply(ctx){
|
|
277
|
-
|
|
883
|
+
// —— 统一收口(0.6.0,P0-6)——
|
|
884
|
+
// 每个工具的返回都补上「我在操作哪张卡」:`cardName` / `cardId`(未关联已存卡时为 null)+
|
|
885
|
+
// `draftStatus`(含 sessionKey / cardId / cardName / rev)。19 个工具各写一遍没有意义,也容易漏。
|
|
886
|
+
// 另外做两件防「静默数据损坏」的事:
|
|
887
|
+
// ① 盘上 rev 与期望不符(模型传了 expectedRev)→ 直接拒绝,别拿过期前提去写;
|
|
888
|
+
// ② **上次调用之后有别人写过这份草稿**(0.7.0 起按 rev 漂移判定,见 S1)→ 返回里给一条明确提示。
|
|
889
|
+
// 新草稿提示(S2)也在这里出:调用前捕获成因,`reset` 不报「内容可能已丢失」。
|
|
890
|
+
const tool = (def) => {
|
|
891
|
+
const raw = def.execute;
|
|
892
|
+
def.execute = async function(args, exec){
|
|
893
|
+
const k = keyFor(ctx, exec);
|
|
894
|
+
const meta = draftMeta(k);
|
|
895
|
+
const expected = (args && typeof args.expectedRev === "number") ? args.expectedRev : null;
|
|
896
|
+
const beforeRev = draftRevOnDisk(k);
|
|
897
|
+
// S1 补充(0.7.1):把「本次调用开始前工具半记着的盘上版本」捕下来,返回里一并报出 ——
|
|
898
|
+
// 模型可以随时对账:`watchedRev ≠ rev` 就说明这两次调用之间有别人写过。第五轮实测里,
|
|
899
|
+
// 测试者想验证「不传 expectedRev 也能发现漂移」,却在工具序列内造不出漂移(自己写的 rev
|
|
900
|
+
// 会被记成自己的),有了这个字段它就能自己看懂机制,不必靠猜。
|
|
901
|
+
const watchedRevAtStart = (typeof meta.rev === "number") ? meta.rev : null;
|
|
902
|
+
// —— S1(0.7.0):不传 expectedRev 时的「被动提醒」——
|
|
903
|
+
// 上次调用结束时我记着的 rev(meta.rev)与盘上现在的不一致,说明**在我两次动手之间别人写过**:
|
|
904
|
+
// 工坊界面点了保存、host 恢复、或另一个会话在动同一把槽。弱模型不会主动传 expectedRev,
|
|
905
|
+
// 但它至少该知道自己手上的前提过期了 —— 第四轮实测里 LLM 就是在这里完全无感知的。
|
|
906
|
+
// 自己不传 expectedRev 的写调用不算漂移(rev 由本次自己推进,后置会同步回 meta.rev)。
|
|
907
|
+
const driftFrom = (expected === null && meta.rev !== null && beforeRev !== null && beforeRev !== meta.rev) ? meta.rev : null;
|
|
908
|
+
if(expected !== null){
|
|
909
|
+
const actual = beforeRev;
|
|
910
|
+
if(actual !== null && actual !== expected){
|
|
911
|
+
const d = getDraft(ctx, exec);
|
|
912
|
+
const st = draftStatusFor(k, d);
|
|
913
|
+
return sanitizeJson({ ok:false, staleRev:true, expectedRev:expected, actualRev:actual, build: BUILD,
|
|
914
|
+
cardName: st.cardName, cardId: st.cardId, draftStatus: st,
|
|
915
|
+
error:"草稿已被外部修改(期望 rev=" + expected + ",实际 rev=" + actual + ")—— 你手上的前提过期了。请先 cc_get_card 确认当前卡片(当前卡名:「" + (st.cardName || "未命名") + "」)再决定要不要重试。" });
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const res = await raw(args, exec);
|
|
919
|
+
try{
|
|
920
|
+
const d = getDraft(ctx, exec);
|
|
921
|
+
const st = draftStatusFor(k, d);
|
|
922
|
+
const afterRev = draftRevOnDisk(k);
|
|
923
|
+
if(afterRev !== null) meta.rev = afterRev; // 记下「本次调用结束时我见到的版本」,供下一次比对
|
|
924
|
+
if(res && typeof res === "object"){
|
|
925
|
+
if(!res.draftStatus) res.draftStatus = st;
|
|
926
|
+
res.draftStatus.watchedRev = watchedRevAtStart;
|
|
927
|
+
res.cardName = st.cardName;
|
|
928
|
+
res.cardId = st.cardId;
|
|
929
|
+
res.build = BUILD; // 版本标识:测试者能一眼确认「我测的是不是最新代码」(第五轮流程建议)
|
|
930
|
+
if(driftFrom !== null){
|
|
931
|
+
// 显式字段 + 一句话:报告里 S1 要的「不传 rev 也能看出过期」就是这两个东西。
|
|
932
|
+
res.revDrift = { from: driftFrom, to: beforeRev };
|
|
933
|
+
res.warnings = (Array.isArray(res.warnings) ? res.warnings : []).concat([externalChangeNotice(k, d, driftFrom, beforeRev)]);
|
|
934
|
+
}
|
|
935
|
+
// S2(0.7.0):新草稿提示只在这里出一次 —— `pendingFresh` 由 getDraft 在「内存与磁盘都
|
|
936
|
+
// 没有草稿、现建了一张空卡」时打上,cc_new_card 主动重置则清掉它(有意重置 ≠ 内容丢失)。
|
|
937
|
+
const freshReason = meta.pendingFresh;
|
|
938
|
+
if(freshReason){
|
|
939
|
+
meta.pendingFresh = null;
|
|
940
|
+
const note = freshNotice({ pendingFresh: freshReason });
|
|
941
|
+
if(note && !(Array.isArray(res.warnings) && res.warnings.indexOf(note) >= 0)){
|
|
942
|
+
res.warnings = (Array.isArray(res.warnings) ? res.warnings : []).concat([note]);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}catch(e){}
|
|
947
|
+
// B1 的第二道防线:无论各工具返回什么,出门前统一过一遍 JSON 往返,绝不把 `undefined`
|
|
948
|
+
// 留给 DSH 的 lossless-JSON 校验(一个 undefined 就会让整次工具调用报错)。
|
|
949
|
+
return sanitizeJson(res);
|
|
950
|
+
};
|
|
951
|
+
return ctx.tools.register(def);
|
|
952
|
+
};
|
|
953
|
+
tool({
|
|
278
954
|
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"},
|
|
955
|
+
description: "【工作流第1步·必调·先问再填】查看当前 CC 角色卡草稿与工作流进度。CC 模式下必须先调此工具,然后用自然语言向用户总结当前进度(角色/五维/世界书/问候语各缺什么),并用 1-2 个开放问题邀请用户表达偏好,再按步推进。严禁未与用户讨论就直接推断填满。卡较大时可用 fields 只取需要的字段,省上下文。",
|
|
956
|
+
parameters: { type:"object", properties:{ fields:{ type:"array", items:{ type:"string" }, description:"可选:只取 data 下的这些字段(如 [\"name\",\"tags\"]);省略则返回整份 draft" } }, additionalProperties:false },
|
|
957
|
+
output: out({ type:"object", additionalProperties:true, properties:{ draft:{type:"object"}, workflow:{type:"object"} } }),
|
|
282
958
|
async execute(args, exec){
|
|
283
959
|
const draft = getDraft(ctx, exec);
|
|
284
960
|
const k = keyFor(ctx, exec);
|
|
285
961
|
const st = draftStatusFor(k, draft);
|
|
286
962
|
const wf = workflowStatus(draft);
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
963
|
+
const warnings = [recoveredNotice(k, draft)].filter(Boolean); // 新草稿提示由 tool() 包装统一注入(S2,只出一份)
|
|
964
|
+
const fields = Array.isArray(args.fields) ? args.fields.map(String).filter(Boolean) : [];
|
|
965
|
+
let outDraft = draft;
|
|
966
|
+
if(fields.length){
|
|
967
|
+
const data = {};
|
|
968
|
+
for(const f of fields){ if(Object.prototype.hasOwnProperty.call(draft.data, f)) data[f] = draft.data[f]; }
|
|
969
|
+
outDraft = { spec: draft.spec, spec_version: draft.spec_version, data };
|
|
970
|
+
}
|
|
971
|
+
const res = { ok:true, draft: outDraft, draftStatus: st, workflow: wf,
|
|
972
|
+
// R2:message 不再与 hint 一字不差 —— 它是**给用户看**的一句复述(进度 + 下一步),
|
|
973
|
+
// 而 workflow.hint 只报进度、workflow.next 只报下一步(各司其职,不再互相复制)。
|
|
974
|
+
message: "当前进度:" + wf.hint + (wf.done ? "。" : "。下一步:" + wf.next) };
|
|
975
|
+
if(fields.length) res.fieldsReturned = Object.keys(outDraft.data);
|
|
976
|
+
if(warnings.length) res.warnings = warnings;
|
|
977
|
+
return res;
|
|
291
978
|
}
|
|
292
979
|
});
|
|
293
980
|
|
|
294
|
-
|
|
981
|
+
tool({
|
|
295
982
|
name: "cc_patch_character",
|
|
296
983
|
description: "【工作流第2步·共创】写入角色基础(name/nickname/description/personality/scenario/system_prompt/tags)。调用前必须已与用户讨论:至少问 1 个开放问题(如想要的气质/性别/年龄/与你的关系张力/禁忌),拿到用户偏好后再填;禁止仅凭世界观自行推断一次性填满。必须在 cc_get_card 之后、五维之前调用,之后下一步是 cc_patch_world。",
|
|
297
984
|
parameters: {
|
|
@@ -305,7 +992,12 @@ function apply(ctx){
|
|
|
305
992
|
system_prompt:{type:"string", description:"system_prompt"},
|
|
306
993
|
post_history_instructions:{type:"string", description:"post_history_instructions"},
|
|
307
994
|
tags:{type:"array", items:{type:"string"}, description:"标签数组,会整体覆盖"},
|
|
308
|
-
creator_notes:{type:"string", description:"creator_notes"}
|
|
995
|
+
creator_notes:{type:"string", description:"creator_notes"},
|
|
996
|
+
// N1(0.7.0):`data.creator` 此前**没有任何写入路径** —— cc_validate 每次都警告「creator 为空」,
|
|
997
|
+
// 但工具侧和工坊界面都改不了它,等于反复催一个没有解法的问题(第四轮实测:传进来被静默忽略)。
|
|
998
|
+
creator:{type:"string", description:"作者名(data.creator)。cc_validate 会警告它为空时导出后看不到作者,这里就是它的写入路径。"},
|
|
999
|
+
verbose:{type:"boolean", description:"可选:true 时额外回吐整份 draft;默认只回 changed 字段名列表(省上下文)"},
|
|
1000
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev,避免基于过期草稿覆盖别人的修改"}
|
|
309
1001
|
},
|
|
310
1002
|
additionalProperties:false
|
|
311
1003
|
},
|
|
@@ -313,26 +1005,37 @@ function apply(ctx){
|
|
|
313
1005
|
async execute(args, exec){
|
|
314
1006
|
const k = keyFor(ctx, exec);
|
|
315
1007
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
316
|
-
const wasFresh = draftMeta(k).isNew;
|
|
317
1008
|
const recNote = recoveredNotice(k, cur);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if(args.
|
|
324
|
-
if(args.
|
|
325
|
-
if(
|
|
326
|
-
if(args.
|
|
1009
|
+
// 合并式更新:只有显式传了的字段才动。**传空字符串 = 清空该字段**(P2-10 之前文档没写清),
|
|
1010
|
+
// 不传 = 保持原值。
|
|
1011
|
+
const changed = [];
|
|
1012
|
+
// B2(0.7.1):`changed` 与 `rev` 用同一套判据 —— 只有值真的变了才记改动,否则会出现
|
|
1013
|
+
// 「返回说改了 creator、rev 却纹丝不动」这种自相矛盾(第五轮实测 B2)。
|
|
1014
|
+
if(args.name!==undefined) assignIfChanged(cur.data, "name", String(args.name).slice(0,120), changed);
|
|
1015
|
+
if(args.nickname!==undefined) assignIfChanged(cur.data, "nickname", String(args.nickname).slice(0,120), changed);
|
|
1016
|
+
if(args.description!==undefined) assignIfChanged(cur.data, "description", String(args.description).slice(0,5000), changed);
|
|
1017
|
+
if(args.personality!==undefined) assignIfChanged(cur.data, "personality", String(args.personality).slice(0,5000), changed);
|
|
1018
|
+
if(args.scenario!==undefined) assignIfChanged(cur.data, "scenario", String(args.scenario).slice(0,5000), changed);
|
|
1019
|
+
if(args.system_prompt!==undefined) assignIfChanged(cur.data, "system_prompt", String(args.system_prompt).slice(0,3000), changed);
|
|
1020
|
+
if(args.post_history_instructions!==undefined) assignIfChanged(cur.data, "post_history_instructions", String(args.post_history_instructions).slice(0,3000), changed);
|
|
1021
|
+
if(Array.isArray(args.tags)) assignIfChanged(cur.data, "tags", args.tags.map(String).slice(0,12), changed);
|
|
1022
|
+
if(args.creator_notes!==undefined) assignIfChanged(cur.data, "creator_notes", String(args.creator_notes).slice(0,3000), changed);
|
|
1023
|
+
if(args.creator!==undefined) assignIfChanged(cur.data, "creator", String(args.creator).slice(0,120), changed);
|
|
327
1024
|
setDraft(ctx, cur, exec);
|
|
328
1025
|
const wf = workflowStatus(cur);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
1026
|
+
const warnings = [];
|
|
1027
|
+
if(recNote) warnings.push(recNote);
|
|
1028
|
+
// P1-4:patch 类默认不再回吐整份 draft —— 长卡下每改一个字段都要模型重读全卡,纯烧上下文。
|
|
1029
|
+
// 需要全量时显式传 verbose:true(cc_get_card 也能按 fields 取)。
|
|
1030
|
+
const res = { ok:true, changed, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1031
|
+
message: "已写入胶囊(角色基础:" + (changed.length ? changed.join("/") : "无改动") + ")。" + wf.next };
|
|
1032
|
+
if(args.verbose === true) res.draft = cur;
|
|
1033
|
+
if(warnings.length) res.warnings = warnings;
|
|
1034
|
+
return res;
|
|
332
1035
|
}
|
|
333
1036
|
});
|
|
334
1037
|
|
|
335
|
-
|
|
1038
|
+
tool({
|
|
336
1039
|
name: "cc_patch_world",
|
|
337
1040
|
description: "【工作流第3步·共创·必调】写入 5 维世界观(timeline/factions/geo/power/daily 至少 3 项)。若用户已提供世界观文档,先用 2-3 句归纳你理解的 5 维,再问用户“哪几维最想展开?有无私设要加?”;若世界观薄弱,则与用户讨论 1-2 个薄弱点后再填。严禁未与用户讨论就直接照搬文档填满 5 维。调后下一步是 cc_add_lorebook_entries。",
|
|
338
1041
|
parameters: {
|
|
@@ -343,7 +1046,14 @@ function apply(ctx){
|
|
|
343
1046
|
geo:{type:"string", description:"地理"},
|
|
344
1047
|
power:{type:"string", description:"力量体系"},
|
|
345
1048
|
daily:{type:"string", description:"日常"},
|
|
346
|
-
autoLorebook:{type:"boolean", description:"是否基于 5 维自动生成 3-6 条带 @@decorator 的 lorebook 条目,强烈建议 true"}
|
|
1049
|
+
autoLorebook:{type:"boolean", description:"是否基于 5 维自动生成 3-6 条带 @@decorator 的 lorebook 条目,强烈建议 true"},
|
|
1050
|
+
loreKeys:{
|
|
1051
|
+
type:"object",
|
|
1052
|
+
description:"**强烈建议提供**:为每一维自报触发词(真实对话里用户会说的实体名),如 {\"geo\":[\"旧校舍\",\"广播室\",\"天台\"],\"factions\":[\"夜巡\",\"学生会\"]}。不给的话自动条目只能用维度名当 key —— 用户永远不会说「地理」,那几条等于死条目。",
|
|
1053
|
+
additionalProperties:{ type:"array", items:{type:"string"} }
|
|
1054
|
+
},
|
|
1055
|
+
verbose:{type:"boolean", description:"可选:true 时额外回吐整份 draft;默认只回状态与条目摘要"},
|
|
1056
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev,避免基于过期草稿覆盖别人的修改"}
|
|
347
1057
|
},
|
|
348
1058
|
additionalProperties:false
|
|
349
1059
|
},
|
|
@@ -351,7 +1061,6 @@ function apply(ctx){
|
|
|
351
1061
|
async execute(args, exec){
|
|
352
1062
|
const k = keyFor(ctx, exec);
|
|
353
1063
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
354
|
-
const wasFresh = draftMeta(k).isNew;
|
|
355
1064
|
const recNote = recoveredNotice(k, cur);
|
|
356
1065
|
cur.data.extensions = cur.data.extensions || {};
|
|
357
1066
|
const w = cur.data.extensions.cc_world || {};
|
|
@@ -362,36 +1071,97 @@ function apply(ctx){
|
|
|
362
1071
|
if(args.daily!==undefined) w.daily = String(args.daily).slice(0,1000);
|
|
363
1072
|
cur.data.extensions.cc_world = w;
|
|
364
1073
|
const shouldAuto = args.autoLorebook !== false; // 默认 true,防偷懒
|
|
1074
|
+
const autoDropped = [];
|
|
1075
|
+
const autoEdited = [];
|
|
365
1076
|
if(shouldAuto){
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
1077
|
+
// 移除先前由 autoLorebook 生成的旧条目(保留手动条目)。
|
|
1078
|
+
// ⚠️ 0.5.0 修:旧数据没有 cc_auto 标记时**不能只按名字删** —— 用户手工建的、恰好叫「日常」的条目
|
|
1079
|
+
// 会被静默吃掉(LLM 侧实测之外的源码复核发现)。现在额外要求内容确实是自动格式(@@ 开头或英文维度 key)。
|
|
1080
|
+
// ⚠️ 0.6.0 补(R4):被删的条目里如果有**手工改过 keys** 的,要报出来 —— 第二轮实测中用户手工把 5 条
|
|
1081
|
+
// keys 改成实体词,重新生成后全被覆盖回维度名,而返回只说「替换了 5 条」,用户毫无察觉。
|
|
369
1082
|
cur.data.character_book.entries = cur.data.character_book.entries.filter(e => {
|
|
370
|
-
if(e
|
|
371
|
-
|
|
372
|
-
|
|
1083
|
+
if(isAutoEntry(e) || looksLikeLegacyAuto(e)){
|
|
1084
|
+
autoDropped.push(String(e.name||""));
|
|
1085
|
+
const ref = (e.extensions && e.extensions.cc_world_ref) || null;
|
|
1086
|
+
const keys = Array.isArray(e.keys) ? e.keys.map(String) : [];
|
|
1087
|
+
const defaults = ref ? [DIM_NAMES[ref], DIM_EN[ref]] : null;
|
|
1088
|
+
const edited = defaults
|
|
1089
|
+
? (keys.length !== defaults.length || keys.some((x, i) => x !== defaults[i]))
|
|
1090
|
+
: (keys.length > 2 || (keys.length === 1 && !AUTO_NAMES.has(keys[0])));
|
|
1091
|
+
if(edited) autoEdited.push(String(e.name||""));
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
373
1094
|
return true;
|
|
374
1095
|
});
|
|
375
1096
|
// 重新计算 insertion_order 保持连续(手动条目保留相对顺序)
|
|
376
1097
|
cur.data.character_book.entries.forEach((e, i) => e.insertion_order = i);
|
|
377
|
-
|
|
378
|
-
|
|
1098
|
+
// 触发词:模型自报的 loreKeys 优先,维度名固定作为最后一条兜底(至少能命中「年表」这种直球提问)
|
|
1099
|
+
const given = (dim, fallback) => {
|
|
1100
|
+
const raw = (args.loreKeys && Array.isArray(args.loreKeys[dim])) ? args.loreKeys[dim].map(String).map(s=>s.trim()).filter(Boolean) : [];
|
|
1101
|
+
return raw.concat(fallback.filter(k => raw.indexOf(k) < 0)).slice(0, 12);
|
|
1102
|
+
};
|
|
1103
|
+
const push=(dim, content, priority, constant=false)=>{
|
|
1104
|
+
const name = DIM_NAMES[dim];
|
|
1105
|
+
cur.data.character_book.entries.push({
|
|
1106
|
+
keys: given(dim, [name, DIM_EN[dim]]),
|
|
1107
|
+
content, enabled:true, insertion_order: cur.data.character_book.entries.length,
|
|
1108
|
+
case_sensitive:false, use_regex:false, constant, name, priority,
|
|
1109
|
+
id: Date.now()+Math.floor(Math.random()*10000), cc_auto:true,
|
|
1110
|
+
extensions:{ cc_auto:true, cc_world_ref: dim },
|
|
1111
|
+
});
|
|
379
1112
|
};
|
|
380
|
-
if(w.timeline) push("
|
|
381
|
-
if(w.factions) push("
|
|
382
|
-
if(w.geo) push("
|
|
383
|
-
if(w.power) push("
|
|
384
|
-
if(w.daily) push("
|
|
1113
|
+
if(w.timeline) push("timeline", "@@position after_desc\n"+w.timeline, 10);
|
|
1114
|
+
if(w.factions) push("factions", "@@depth 4\n"+w.factions, 9);
|
|
1115
|
+
if(w.geo) push("geo", "@@position scenario\n"+w.geo, 8);
|
|
1116
|
+
if(w.power) push("power", "@@activate\n"+w.power, 9, true);
|
|
1117
|
+
if(w.daily) push("daily", "@@depth 5\n"+w.daily, 7);
|
|
385
1118
|
}
|
|
386
1119
|
setDraft(ctx, cur, exec);
|
|
387
1120
|
const wf = workflowStatus(cur);
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
1121
|
+
const autoEntries = cur.data.character_book.entries.filter(isAutoEntry);
|
|
1122
|
+
const warnings = [];
|
|
1123
|
+
if(recNote) warnings.push(recNote);
|
|
1124
|
+
const gaveKeys = !!(args.loreKeys && Object.keys(args.loreKeys).length);
|
|
1125
|
+
if(autoEntries.length && !gaveKeys){
|
|
1126
|
+
warnings.push("自动生成的 " + autoEntries.length + " 条世界书条目只用了维度名当触发词(年表/timeline 这类),真实对话几乎不会命中。建议用 cc_update_lorebook_entry 改成用户会说的实体名(如「旧校舍」「广播室」),或下次调用 cc_patch_world 时带上 loreKeys。");
|
|
1127
|
+
}
|
|
1128
|
+
// N2 附带(0.7.0):同一个实体被分给多个维度时**当场说破** —— 第四轮实测里「剑冢」同时落在
|
|
1129
|
+
// factions / geo / power,一句「剑冢到底是什么地方」就触发了 4 条、1139 token(预算 1200)。
|
|
1130
|
+
// 此前只有写完之后跑 cc_lint_lorebook 才会报「触发词被多条条目共用」,反馈太晚。
|
|
1131
|
+
const sharedLoreKeys = (() => {
|
|
1132
|
+
if(!args.loreKeys || typeof args.loreKeys !== "object") return [];
|
|
1133
|
+
const seen = new Map();
|
|
1134
|
+
Object.keys(args.loreKeys).forEach(dim => {
|
|
1135
|
+
(Array.isArray(args.loreKeys[dim]) ? args.loreKeys[dim] : []).forEach(raw => {
|
|
1136
|
+
const key = String(raw).trim();
|
|
1137
|
+
if(!key) return;
|
|
1138
|
+
if(!seen.has(key)) seen.set(key, []);
|
|
1139
|
+
if(seen.get(key).indexOf(dim) < 0) seen.get(key).push(dim);
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
return [...seen.entries()].filter(pair => pair[1].length > 1).map(pair => pair[0] + "(" + pair[1].join("/") + ")");
|
|
1143
|
+
})();
|
|
1144
|
+
if(shouldAuto && sharedLoreKeys.length){
|
|
1145
|
+
warnings.push("这些触发词被同时分给了多个维度:" + sharedLoreKeys.join("、") + " —— 用户一句话提到它们会**同时触发多条**,token 消耗成倍叠加。建议一个实体只留给最贴切的那个维度。");
|
|
1146
|
+
}
|
|
1147
|
+
// N2(0.7.0):自动条目的 content 是五维**原文照搬**,维度写得越细,单次命中越贵。
|
|
1148
|
+
const heavyAuto = autoEntries.filter(e => estimateTokens(e.content) > 400);
|
|
1149
|
+
if(heavyAuto.length){
|
|
1150
|
+
warnings.push("有 " + heavyAuto.length + " 条自动条目的内容超过 400 token(" + heavyAuto.map(e => String(e.name||"")).join("、") + "):auto 条目的 content 是五维原文照搬,写多细就多重,每次命中都会吃这么多上下文。可用 cc_update_lorebook_entry 精炼成摘要,或把对应维度缩短。");
|
|
1151
|
+
}
|
|
1152
|
+
const res = { ok:true, worldFilled: wf.worldFilled, loreCount: wf.loreCount,
|
|
1153
|
+
autoLoreAdded: autoEntries.length, autoLoreReplaced: autoDropped.length, autoLoreEdited: autoEdited.length,
|
|
1154
|
+
sharedLoreKeys, heavyAutoEntries: heavyAuto.length,
|
|
1155
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1156
|
+
message: "已写入五维(" + wf.worldFilled + "/5),自动生成 " + autoEntries.length + " 条基础条目。" + wf.next };
|
|
1157
|
+
if(autoEdited.length) warnings.push("被替换的 " + autoEdited.length + " 条旧自动条目(" + autoEdited.join("、") + ")的 keys 曾被手工改过,这次已重建回默认维度名,手工触发词会丢。想保住它们:先用 cc_update_lorebook_entry 写回,或以后调本工具时带上 loreKeys。");
|
|
1158
|
+
if(args.verbose === true) res.draft = cur;
|
|
1159
|
+
if(warnings.length) res.warnings = warnings;
|
|
1160
|
+
return res;
|
|
391
1161
|
}
|
|
392
1162
|
});
|
|
393
1163
|
|
|
394
|
-
|
|
1164
|
+
tool({
|
|
395
1165
|
name: "cc_add_lorebook_entries",
|
|
396
1166
|
description: "【工作流第4步·共创·必调】添加 1-5 条 Lorebook,补到至少 5 条(年表/势力/地理/力量/日常各 1,至少 1 条 constant,@@decorator)。调用前应与用户讨论:问用户想要哪些触发词/常驻条目/要藏什么彩蛋,拿到偏好后再生成。必须在五维之后、问候语之前调用,禁止一次性无讨论批量生成。",
|
|
397
1167
|
parameters: {
|
|
@@ -412,7 +1182,8 @@ function apply(ctx){
|
|
|
412
1182
|
required:["content"],
|
|
413
1183
|
additionalProperties:false
|
|
414
1184
|
}
|
|
415
|
-
}
|
|
1185
|
+
},
|
|
1186
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
416
1187
|
},
|
|
417
1188
|
required:["entries"],
|
|
418
1189
|
additionalProperties:false
|
|
@@ -421,7 +1192,6 @@ function apply(ctx){
|
|
|
421
1192
|
async execute(args, exec){
|
|
422
1193
|
const k = keyFor(ctx, exec);
|
|
423
1194
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
424
|
-
const wasFresh = draftMeta(k).isNew;
|
|
425
1195
|
const recNote = recoveredNotice(k, cur);
|
|
426
1196
|
args.entries.forEach(function(e){
|
|
427
1197
|
cur.data.character_book.entries.push({
|
|
@@ -439,20 +1209,25 @@ function apply(ctx){
|
|
|
439
1209
|
});
|
|
440
1210
|
setDraft(ctx, cur, exec);
|
|
441
1211
|
const wf = workflowStatus(cur);
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
1212
|
+
const warnings = [];
|
|
1213
|
+
if(recNote) warnings.push(recNote);
|
|
1214
|
+
const res = { ok:true, added: args.entries.length, loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount,
|
|
1215
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1216
|
+
message: "已新增 " + args.entries.length + " 条世界书条目(共 " + wf.loreCount + " 条,其中共创 " + wf.manualLoreCount + " 条)。" + wf.next };
|
|
1217
|
+
if(warnings.length) res.warnings = warnings;
|
|
1218
|
+
return res;
|
|
445
1219
|
}
|
|
446
1220
|
});
|
|
447
1221
|
|
|
448
|
-
|
|
1222
|
+
tool({
|
|
449
1223
|
name: "cc_delete_lorebook_entries",
|
|
450
1224
|
description: "【Lorebook 管理】按 id 或 name 删除条目(用于清理过时条目,autoLorebook 已自动覆盖旧自动条目,手动条目需显式删除)。至少提供 ids 或 names 之一。",
|
|
451
1225
|
parameters: {
|
|
452
1226
|
type:"object",
|
|
453
1227
|
properties:{
|
|
454
1228
|
ids:{type:"array", items:{type:"number"}, description:"要删除的条目 id 列表"},
|
|
455
|
-
names:{type:"array", items:{type:"string"}, description:"要删除的条目 name 列表(精确匹配)"}
|
|
1229
|
+
names:{type:"array", items:{type:"string"}, description:"要删除的条目 name 列表(精确匹配)"},
|
|
1230
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
456
1231
|
},
|
|
457
1232
|
additionalProperties:false
|
|
458
1233
|
},
|
|
@@ -462,7 +1237,6 @@ function apply(ctx){
|
|
|
462
1237
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
463
1238
|
const before = draftStatusFor(k, cur);
|
|
464
1239
|
const beforeRec = recoveredNotice(k, cur);
|
|
465
|
-
const beforeFresh = before.isNew;
|
|
466
1240
|
const beforeCount = cur.data.character_book.entries.length;
|
|
467
1241
|
const idSet = new Set(Array.isArray(args.ids)? args.ids.map(Number): []);
|
|
468
1242
|
const nameSet = new Set(Array.isArray(args.names)? args.names.map(String): []);
|
|
@@ -472,13 +1246,18 @@ function apply(ctx){
|
|
|
472
1246
|
cur.data.character_book.entries.forEach((e,i)=> e.insertion_order=i);
|
|
473
1247
|
setDraft(ctx, cur, exec);
|
|
474
1248
|
const wf = workflowStatus(cur);
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
1249
|
+
const deleted = beforeCount - wf.loreCount;
|
|
1250
|
+
const warnings = [];
|
|
1251
|
+
if(beforeRec) warnings.push(beforeRec);
|
|
1252
|
+
const res = { ok:true, deleted, loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount,
|
|
1253
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1254
|
+
message: "已删除 " + deleted + " 条世界书条目(剩 " + wf.loreCount + " 条,其中共创 " + wf.manualLoreCount + " 条)。" + wf.next };
|
|
1255
|
+
if(warnings.length) res.warnings = warnings;
|
|
1256
|
+
return res;
|
|
478
1257
|
}
|
|
479
1258
|
});
|
|
480
1259
|
|
|
481
|
-
|
|
1260
|
+
tool({
|
|
482
1261
|
name: "cc_update_lorebook_entry",
|
|
483
1262
|
description: "【Lorebook 管理】按 id 更新单条条目(content/keys/name 等),用于修正过时内容而非整批重建。",
|
|
484
1263
|
parameters: {
|
|
@@ -491,7 +1270,8 @@ function apply(ctx){
|
|
|
491
1270
|
enabled:{type:"boolean"},
|
|
492
1271
|
constant:{type:"boolean"},
|
|
493
1272
|
use_regex:{type:"boolean"},
|
|
494
|
-
priority:{type:"number"}
|
|
1273
|
+
priority:{type:"number"},
|
|
1274
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
495
1275
|
},
|
|
496
1276
|
required:["id"],
|
|
497
1277
|
additionalProperties:false
|
|
@@ -500,26 +1280,30 @@ function apply(ctx){
|
|
|
500
1280
|
async execute(args, exec){
|
|
501
1281
|
const k = keyFor(ctx, exec);
|
|
502
1282
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
503
|
-
const wasFresh = draftMeta(k).isNew;
|
|
504
1283
|
const recNote = recoveredNotice(k, cur);
|
|
505
1284
|
const e = cur.data.character_book.entries.find(x=> x.id===args.id);
|
|
506
1285
|
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(
|
|
1286
|
+
const changed = [];
|
|
1287
|
+
if(args.name!==undefined) assignIfChanged(e, "name", String(args.name).slice(0,60), changed);
|
|
1288
|
+
if(Array.isArray(args.keys)) assignIfChanged(e, "keys", args.keys.map(String), changed);
|
|
1289
|
+
if(args.content!==undefined) assignIfChanged(e, "content", String(args.content).slice(0,5000), changed);
|
|
1290
|
+
if(args.enabled!==undefined) assignIfChanged(e, "enabled", !!args.enabled, changed);
|
|
1291
|
+
if(args.constant!==undefined) assignIfChanged(e, "constant", !!args.constant, changed);
|
|
1292
|
+
if(args.use_regex!==undefined) assignIfChanged(e, "use_regex", !!args.use_regex, changed);
|
|
1293
|
+
if(typeof args.priority==="number") assignIfChanged(e, "priority", args.priority, changed);
|
|
514
1294
|
setDraft(ctx, cur, exec);
|
|
515
1295
|
const wf = workflowStatus(cur);
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
1296
|
+
const warnings = [];
|
|
1297
|
+
if(recNote) warnings.push(recNote);
|
|
1298
|
+
const res = { ok:true, entry:e, changed, loreCount: wf.loreCount,
|
|
1299
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1300
|
+
message: "已更新条目「" + String(e.name||"") + "」(" + changed.join("/") + ")。" + wf.next };
|
|
1301
|
+
if(warnings.length) res.warnings = warnings;
|
|
1302
|
+
return res;
|
|
519
1303
|
}
|
|
520
1304
|
});
|
|
521
1305
|
|
|
522
|
-
|
|
1306
|
+
tool({
|
|
523
1307
|
name: "cc_patch_greetings",
|
|
524
1308
|
description: "【工作流第5步·共创】写入开场与示例(first_mes / alternate_greetings≥2 / group_only_greetings≥1 / mes_example)。调用前必须问用户偏好的开场场景/关系阶段/群聊与私聊区分,拿到偏好后再写;禁止未与用户讨论就自行编造全部问候语。必须在角色+五维+世界书(≥5)后调用。",
|
|
525
1309
|
parameters: {
|
|
@@ -528,7 +1312,9 @@ function apply(ctx){
|
|
|
528
1312
|
first_mes:{type:"string", description:"首条开场白"},
|
|
529
1313
|
alternate_greetings:{type:"array", items:{type:"string"}, description:"备选开场白数组,至少 2 条"},
|
|
530
1314
|
group_only_greetings:{type:"array", items:{type:"string"}, description:"群聊限定开场白(必填至少1条)"},
|
|
531
|
-
mes_example:{type:"string", description:"对话示例(可用 {{user}}/{{char}})"}
|
|
1315
|
+
mes_example:{type:"string", description:"对话示例(可用 {{user}}/{{char}})"},
|
|
1316
|
+
verbose:{type:"boolean", description:"可选:true 时额外回吐整份 draft;默认只回问候语计数与状态"},
|
|
1317
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev,避免基于过期草稿覆盖别人的修改"}
|
|
532
1318
|
},
|
|
533
1319
|
additionalProperties:false
|
|
534
1320
|
},
|
|
@@ -536,7 +1322,6 @@ function apply(ctx){
|
|
|
536
1322
|
async execute(args, exec){
|
|
537
1323
|
const k = keyFor(ctx, exec);
|
|
538
1324
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
539
|
-
const wasFresh = draftMeta(k).isNew;
|
|
540
1325
|
const recNote = recoveredNotice(k, cur);
|
|
541
1326
|
if(args.first_mes!==undefined) cur.data.first_mes = String(args.first_mes).slice(0,5000);
|
|
542
1327
|
if(Array.isArray(args.alternate_greetings)) cur.data.alternate_greetings = args.alternate_greetings.map(String).slice(0,10);
|
|
@@ -545,17 +1330,23 @@ function apply(ctx){
|
|
|
545
1330
|
setDraft(ctx, cur, exec);
|
|
546
1331
|
const wf = workflowStatus(cur);
|
|
547
1332
|
// 若世界书/五维未齐,警告但仍写入,让 validate 卡住
|
|
548
|
-
|
|
549
|
-
if(
|
|
550
|
-
if(
|
|
551
|
-
if(
|
|
552
|
-
const
|
|
553
|
-
|
|
1333
|
+
const warnings = [];
|
|
1334
|
+
if(recNote) warnings.push(recNote);
|
|
1335
|
+
if(wf.state.world !== "ok") warnings.push("五维仅 " + wf.worldFilled + "/5 项(未达 3 项),validate 会失败,请先补 cc_patch_world。");
|
|
1336
|
+
if(wf.state.lore !== "ok") warnings.push("世界书未达「≥5 条且至少 1 条共创」(当前 " + wf.loreCount + " 条,其中共创 " + wf.manualLoreCount + " 条),validate 会失败,请先补 cc_add_lorebook_entries。");
|
|
1337
|
+
const warnText = warnings.length ? "(" + warnings.join(" ") + ")" : "";
|
|
1338
|
+
const res = { ok:true,
|
|
1339
|
+
greetings: { first_mes: !!String(cur.data.first_mes||"").trim(), alternate: (cur.data.alternate_greetings||[]).length, group: (cur.data.group_only_greetings||[]).length },
|
|
1340
|
+
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1341
|
+
message: "已写入问候语" + warnText + "。" + wf.next };
|
|
1342
|
+
if(args.verbose === true) res.draft = cur;
|
|
1343
|
+
if(warnings.length) res.warnings = warnings;
|
|
1344
|
+
return res;
|
|
554
1345
|
}
|
|
555
1346
|
});
|
|
556
1347
|
|
|
557
1348
|
// —— 已存角色库 CRUD(ID 化,模型可直接管理,与工坊侧栏共享 ~/.dsh/cc-library) ——
|
|
558
|
-
|
|
1349
|
+
tool({
|
|
559
1350
|
name: "cc_list_library",
|
|
560
1351
|
description: "【库·查】列出已存角色库所有条目(id/name/loreCount/tags/updatedAt),用于让用户挑选载入或让模型决定更新哪一张。",
|
|
561
1352
|
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
@@ -565,64 +1356,473 @@ function apply(ctx){
|
|
|
565
1356
|
return { ok:true, entries, count:entries.length };
|
|
566
1357
|
}
|
|
567
1358
|
});
|
|
568
|
-
|
|
1359
|
+
tool({
|
|
569
1360
|
name: "cc_save_to_library",
|
|
570
|
-
description: "
|
|
571
|
-
parameters: { type:"object", properties:{ id:{type:"string", description:"要覆盖的已存 ID
|
|
1361
|
+
description: "【库·增/改】把当前会话草稿存入已存库(ID 化 CRUD)。**不传 id 时优先沿用草稿的来源 id**(最近一次 cc_load_from_library 载入的那张),所以「载入 → 修改 → 保存」是更新原卡,不会再多出一张同名重复卡;只有从未载入过(或已 cc_new_card 重置)时才新建。传 id 则按该 ID 原地覆盖。返回值 action 说明本次是 created 还是 updated。传 name 会同时写入 meta.name 与 draft.data.name(避免列表名与卡内名两套)。",
|
|
1362
|
+
parameters: { type:"object", properties:{ id:{type:"string", description:"要覆盖的已存 ID(不传则沿用草稿来源 id;没有来源才新建)"}, name:{type:"string", description:"新名称(可选;同时写入 meta.name 与 draft.data.name)"} }, additionalProperties:false },
|
|
572
1363
|
output: out({ type:"object", additionalProperties:true }),
|
|
573
1364
|
async execute(args, exec){
|
|
1365
|
+
const k = keyFor(ctx, exec);
|
|
574
1366
|
const draft=JSON.parse(JSON.stringify(getDraft(ctx,exec)));
|
|
575
1367
|
// 至少需要 name
|
|
576
1368
|
if(!draft.data.name || !String(draft.data.name).trim()) return { ok:false, error:"当前草稿缺少 data.name,请先 cc_patch_character 补齐" };
|
|
577
|
-
const
|
|
578
|
-
|
|
1369
|
+
const explicit = args.id ? String(args.id) : "";
|
|
1370
|
+
const origin = explicit ? null : readOriginSync(k);
|
|
1371
|
+
const targetId = explicit || (origin ? origin.libraryId : "");
|
|
1372
|
+
const res=await saveLibraryEntry(draft, { id: targetId || undefined, name: args.name });
|
|
1373
|
+
// 记来源:下次不传 id 时沿用同一张(cc_new_card / 工坊清空草稿会清掉它)
|
|
1374
|
+
writeOriginSync(k, res.id, res.meta.name);
|
|
1375
|
+
// 0.8.0 修:action 与文案都用 saveLibraryEntry 的**实测判定**(它按「该 id 的文件是否已存在」算)。
|
|
1376
|
+
// 旧写法 `targetId ? "updated" : "created"` 在「传一个库里不存在的新 id」时会谎报 updated ——
|
|
1377
|
+
// 而那正是群聊场景「从母卡派生出多张角色卡」的唯一路径,模型会以为自己覆盖了别人、不敢继续。
|
|
1378
|
+
const created = res.action === "created";
|
|
1379
|
+
return { ok:true, action: res.action, id:res.id, meta:res.meta, loreCount:res.meta.loreCount,
|
|
1380
|
+
message: (created ? "已新建已存卡「" : "已更新已存卡「") + res.meta.name + "」(id " + res.id + ")。" + (created ? "下次保存(不传 id)会更新这一张。" : "") };
|
|
579
1381
|
}
|
|
580
1382
|
});
|
|
581
|
-
|
|
1383
|
+
tool({
|
|
582
1384
|
name: "cc_load_from_library",
|
|
583
|
-
description: "【库·载入】按 ID
|
|
584
|
-
parameters: { type:"object", properties:{ id:{type:"string", description:"已存条目 ID"} }, required:["id"], additionalProperties:false },
|
|
1385
|
+
description: "【库·载入】按 ID 把已存卡载入当前会话草稿(**会覆盖当前 draft**)。载入后会记住来源 id:之后 cc_save_to_library 不传 id 也按这张更新,不会再新建重复卡。",
|
|
1386
|
+
parameters: { type:"object", properties:{ id:{type:"string", description:"已存条目 ID"}, includeDraft:{type:"boolean", description:"默认 true(载入后通常要基于内容继续工作);false 时只回 meta 与状态,省上下文"} }, required:["id"], additionalProperties:false },
|
|
585
1387
|
output: out({ type:"object", additionalProperties:true }),
|
|
586
1388
|
async execute(args, exec){
|
|
587
1389
|
const obj=await loadLibraryEntry(args.id);
|
|
588
1390
|
const cur=JSON.parse(JSON.stringify(obj.draft));
|
|
589
1391
|
setDraft(ctx,cur,exec);
|
|
590
1392
|
const k = keyFor(ctx, exec);
|
|
591
|
-
|
|
1393
|
+
writeOriginSync(k, obj.id, obj.meta.name);
|
|
1394
|
+
const wf = workflowStatus(cur);
|
|
1395
|
+
const res = { ok:true, id:obj.id, meta:obj.meta, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1396
|
+
loreCount: wf.loreCount,
|
|
1397
|
+
message: "已载入「" + obj.meta.name + "」(id " + obj.id + ",世界书 " + wf.loreCount + " 条)。之后保存(不传 id)会更新这一张。" };
|
|
1398
|
+
if(args.includeDraft !== false) res.draft = cur;
|
|
1399
|
+
return res;
|
|
592
1400
|
}
|
|
593
1401
|
});
|
|
594
|
-
|
|
1402
|
+
tool({
|
|
595
1403
|
name: "cc_delete_from_library",
|
|
596
|
-
description: "【库·删】按 ID
|
|
1404
|
+
description: "【库·删】按 ID 删除已存条目,模型可在用户确认后调用。返回值带 remaining(删完后库里还剩几条)。",
|
|
597
1405
|
parameters: { type:"object", properties:{ id:{type:"string", description:"要删除的 ID"} }, required:["id"], additionalProperties:false },
|
|
598
1406
|
output: out({ type:"object", additionalProperties:true }),
|
|
599
1407
|
async execute(args, exec){
|
|
1408
|
+
const k = keyFor(ctx, exec);
|
|
1409
|
+
const origin = readOriginSync(k);
|
|
1410
|
+
const wasOrigin = !!(origin && origin.libraryId === String(args.id));
|
|
600
1411
|
await deleteLibraryEntry(args.id);
|
|
601
|
-
|
|
1412
|
+
// 删掉的正好是当前草稿的来源卡 → 清掉来源,否则下次保存会「更新」一张已不存在的卡
|
|
1413
|
+
if(wasOrigin) writeOriginSync(k, null);
|
|
1414
|
+
const entries = await listLibraryEntries();
|
|
1415
|
+
return { ok:true, id:args.id, remaining: entries.length, originCleared: wasOrigin,
|
|
1416
|
+
message: "已删除(id " + args.id + "),库里还剩 " + entries.length + " 条。" + (wasOrigin ? "当前草稿的来源关联也一并解除。" : "") };
|
|
602
1417
|
}
|
|
603
1418
|
});
|
|
604
|
-
|
|
1419
|
+
tool({
|
|
605
1420
|
name: "cc_rename_in_library",
|
|
606
|
-
description: "【库·改名】按 ID
|
|
607
|
-
parameters: { type:"object", properties:{ id:{type:"string"}, name:{type:"string", description:"
|
|
1421
|
+
description: "【库·改名】按 ID 重命名已存条目。**同时写入 meta.name 与 draft.data.name**(单一事实来源)—— 此前只改 meta,于是侧边栏显示新名、导出的卡里还是旧名,用户反复改名却不明白为什么不生效。",
|
|
1422
|
+
parameters: { type:"object", properties:{ id:{type:"string"}, name:{type:"string", description:"新名称(列表名与卡内 data.name 一起改)"} }, required:["id","name"], additionalProperties:false },
|
|
608
1423
|
output: out({ type:"object", additionalProperties:true }),
|
|
609
1424
|
async execute(args, exec){
|
|
1425
|
+
const k = keyFor(ctx, exec);
|
|
610
1426
|
const meta=await renameLibraryEntry(args.id,args.name);
|
|
611
|
-
|
|
1427
|
+
const origin = readOriginSync(k);
|
|
1428
|
+
if(origin && origin.libraryId === String(args.id)) writeOriginSync(k, String(args.id), meta.name);
|
|
1429
|
+
return { ok:true, meta, syncedDraftName: true,
|
|
1430
|
+
message: "已改名为「" + meta.name + "」(meta.name 与 draft.data.name 同步更新,导出后就是这个名字)。" };
|
|
612
1431
|
}
|
|
613
1432
|
});
|
|
614
|
-
|
|
1433
|
+
tool({
|
|
615
1434
|
name: "cc_get_library_entry",
|
|
616
|
-
description: "【库·查单条】按 ID 获取单条完整草稿与 meta
|
|
617
|
-
parameters: { type:"object", properties:{ id:{type:"string"} }, required:["id"], additionalProperties:false },
|
|
1435
|
+
description: "【库·查单条】按 ID 获取单条完整草稿与 meta,用于模型在重命名/更新前确认内容。只想确认 meta 或某个字段时传 includeDraft:false,省上下文。",
|
|
1436
|
+
parameters: { type:"object", properties:{ id:{type:"string"}, includeDraft:{type:"boolean", description:"默认 true;只想确认 meta/字段时传 false"} }, required:["id"], additionalProperties:false },
|
|
618
1437
|
output: out({ type:"object", additionalProperties:true }),
|
|
619
1438
|
async execute(args, exec){
|
|
620
1439
|
const obj=await loadLibraryEntry(args.id);
|
|
621
|
-
|
|
1440
|
+
const res = { ok:true, id:obj.id, meta:obj.meta };
|
|
1441
|
+
if(args.includeDraft !== false) res.draft = obj.draft;
|
|
1442
|
+
return res;
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// ——————————————————————————————————————————————————————————————
|
|
1447
|
+
// 0.5.0 新增工具:都来自 LLM 侧实测反馈的「无工具可调」清单
|
|
1448
|
+
// ——————————————————————————————————————————————————————————————
|
|
1449
|
+
|
|
1450
|
+
// 【重置】空白起点。破坏性 → 必须显式 confirm,杜绝模型顺手清空用户的卡。
|
|
1451
|
+
tool({
|
|
1452
|
+
name: "cc_new_card",
|
|
1453
|
+
description: "【卡·重置】把当前会话草稿换成一张全新的空白卡(清空所有字段与世界书,并解除与已存卡的来源关联)。这是**破坏性操作**:必须先向用户确认(说明现状 + 影响),拿到明确同意后再带 confirm:true 调用;不带 confirm 时本工具只返回提示、不改任何数据。若用户只是想换一张已存卡继续,用 cc_list_library + cc_load_from_library。",
|
|
1454
|
+
parameters: { type:"object", properties:{ confirm:{type:"boolean", description:"必须在用户明确同意重置后传 true"} }, additionalProperties:false },
|
|
1455
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1456
|
+
async execute(args, exec){
|
|
1457
|
+
const k = keyFor(ctx, exec);
|
|
1458
|
+
if(args.confirm !== true){
|
|
1459
|
+
const cur = getDraft(ctx, exec);
|
|
1460
|
+
const wf = workflowStatus(cur);
|
|
1461
|
+
return { ok:false, needsConfirm:true, workflow: wf,
|
|
1462
|
+
message:"重置会清空当前草稿的全部内容(角色/五维/世界书/问候语)且无法撤销。当前草稿:" + (String(cur.data.name||"").trim() || "(未命名)") + ",世界书 " + wf.loreCount + " 条。请先向用户说明并征求同意;同意后再以 confirm:true 重新调用。" };
|
|
1463
|
+
}
|
|
1464
|
+
const fresh = makeEmptyCard();
|
|
1465
|
+
setDraft(ctx, fresh, exec);
|
|
1466
|
+
writeOriginSync(k, null);
|
|
1467
|
+
// P0-6 附带 bug 修:setDraft 会把 isNew 置 false,但重置出来的确实是一张全新草稿 ——
|
|
1468
|
+
// 第二轮实测里 `isNew` 两轮从未出现过 true,正是漏了这一处(它本该是「这张卡还没跟已存卡建立关联」的信号)。
|
|
1469
|
+
draftMeta(k).isNew = true;
|
|
1470
|
+
// S2(0.7.0):成因是「有意重置」,不是「会话里突然没有草稿了」—— 清掉待告知标记,
|
|
1471
|
+
// 之后写入不再报「此前有未保存的内容可能已丢失」(第四轮实测:刚重置完就被吓一句,纯误报)。
|
|
1472
|
+
draftMeta(k).pendingFresh = null;
|
|
1473
|
+
const wf = workflowStatus(fresh);
|
|
1474
|
+
return { ok:true, reset:true, workflow: wf,
|
|
1475
|
+
message:"已重置为空白草稿(并解除与已存卡的来源关联,之后保存会新建一张)。" + wf.next };
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// 【世界书元信息】此前 name/description/scan_depth/token_budget/recursive_scanning 一个都改不了
|
|
1480
|
+
tool({
|
|
1481
|
+
name: "cc_patch_book_meta",
|
|
1482
|
+
description: "【世界书·元信息】改 character_book 的顶层参数:name / description / scan_depth / token_budget / recursive_scanning。这些字段此前没有任何工具能改,导致导出的世界书永远是无名书、预算也调不了。合并式更新,只改传入的字段。",
|
|
1483
|
+
parameters: {
|
|
1484
|
+
type:"object",
|
|
1485
|
+
properties:{
|
|
1486
|
+
name:{type:"string", description:"世界书名称(character_book.name)"},
|
|
1487
|
+
description:{type:"string", description:"世界书说明(character_book.description)"},
|
|
1488
|
+
scan_depth:{type:"number", description:"扫描深度:在最近 N 条消息里找触发词,常用 2-8"},
|
|
1489
|
+
token_budget:{type:"number", description:"世界书 token 预算,超出会被前端截断"},
|
|
1490
|
+
recursive_scanning:{type:"boolean", description:"是否递归扫描(被触发条目里的内容也能再触发其他条目)"},
|
|
1491
|
+
expectedRev:{type:"number", description:"可选乐观锁:传上次返回里的 draftStatus.rev;若草稿已被外部(工坊界面 / host 恢复)改过,本次调用直接失败并返回 staleRev"}
|
|
1492
|
+
},
|
|
1493
|
+
additionalProperties:false
|
|
1494
|
+
},
|
|
1495
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1496
|
+
async execute(args, exec){
|
|
1497
|
+
const k = keyFor(ctx, exec);
|
|
1498
|
+
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
1499
|
+
cur.data.character_book = cur.data.character_book || {};
|
|
1500
|
+
const book = cur.data.character_book;
|
|
1501
|
+
const changed = [];
|
|
1502
|
+
if(args.name!==undefined) assignIfChanged(book, "name", String(args.name).slice(0,120), changed);
|
|
1503
|
+
if(args.description!==undefined) assignIfChanged(book, "description", String(args.description).slice(0,500), changed);
|
|
1504
|
+
if(typeof args.scan_depth==="number") assignIfChanged(book, "scan_depth", Math.max(1, Math.min(50, Math.round(args.scan_depth))), changed);
|
|
1505
|
+
if(typeof args.token_budget==="number") assignIfChanged(book, "token_budget", Math.max(0, Math.round(args.token_budget)), changed);
|
|
1506
|
+
if(args.recursive_scanning!==undefined) assignIfChanged(book, "recursive_scanning", !!args.recursive_scanning, changed);
|
|
1507
|
+
setDraft(ctx, cur, exec);
|
|
1508
|
+
const wf = workflowStatus(cur);
|
|
1509
|
+
const used = lorebookTokens(book.entries);
|
|
1510
|
+
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 },
|
|
1511
|
+
lorebookTokens: used, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1512
|
+
message: "已更新世界书元信息(" + (changed.length ? changed.join("/") : "无改动") + ")。当前条目内容估算 " + used + " token / 预算 " + book.token_budget + "。" + wf.next };
|
|
1513
|
+
return res;
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
// 【触发模拟】给一句话,看哪些条目会被触发 —— 写入时就能发现死条目,不用等用户聊两句
|
|
1518
|
+
tool({
|
|
1519
|
+
name: "cc_simulate_trigger",
|
|
1520
|
+
description: "【世界书·验】给一句用户真的会说的话,返回哪些条目会被触发(constant 条目永远算命中)+ 估算 token。用来在写入时就发现「keys 是维度名、真实对话永远不会命中」这类死条目。用 text 传一句,或用 texts 传多句批量验。",
|
|
1521
|
+
parameters: {
|
|
1522
|
+
type:"object",
|
|
1523
|
+
properties:{
|
|
1524
|
+
text:{type:"string", description:"一句测试文本,如「我们去旧校舍看看吧」"},
|
|
1525
|
+
texts:{type:"array", items:{type:"string"}, description:"多句批量测试(可与 text 并用)"}
|
|
1526
|
+
},
|
|
1527
|
+
additionalProperties:false
|
|
1528
|
+
},
|
|
1529
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1530
|
+
async execute(args, exec){
|
|
1531
|
+
const cur = getDraft(ctx, exec);
|
|
1532
|
+
const book = cur.data.character_book || {};
|
|
1533
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1534
|
+
const texts = [];
|
|
1535
|
+
if(typeof args.text === "string" && args.text.trim()) texts.push(args.text);
|
|
1536
|
+
if(Array.isArray(args.texts)) args.texts.forEach(x => { if(typeof x === "string" && x.trim()) texts.push(x); });
|
|
1537
|
+
if(!texts.length) return { ok:false, error:"请用 text 或 texts 提供至少一句测试文本" };
|
|
1538
|
+
const hitsOf = (line) => {
|
|
1539
|
+
const lower = line.toLowerCase();
|
|
1540
|
+
return entries.filter(e => {
|
|
1541
|
+
if(!e || e.enabled === false) return false;
|
|
1542
|
+
if(e.constant) return true; // 常驻条目永远命中
|
|
1543
|
+
const keys = Array.isArray(e.keys) ? e.keys : [];
|
|
1544
|
+
return keys.some(k => {
|
|
1545
|
+
const key = String(k);
|
|
1546
|
+
if(!key) return false;
|
|
1547
|
+
if(e.use_regex){ try{ return new RegExp(key, "i").test(line); }catch{ return false; } }
|
|
1548
|
+
return lower.indexOf(key.toLowerCase()) >= 0;
|
|
1549
|
+
});
|
|
1550
|
+
});
|
|
1551
|
+
};
|
|
1552
|
+
const results = texts.map(line => {
|
|
1553
|
+
const hits = hitsOf(line);
|
|
1554
|
+
return {
|
|
1555
|
+
text: line, hitCount: hits.length, tokens: lorebookTokens(hits),
|
|
1556
|
+
hits: hits.map(e => ({ name: String(e.name||""), constant: !!e.constant, keys: Array.isArray(e.keys)?e.keys:[], priority: e.priority })),
|
|
1557
|
+
missed: entries.filter(e => e.enabled !== false && hits.indexOf(e) < 0).map(e => String(e.name||"")),
|
|
1558
|
+
};
|
|
1559
|
+
});
|
|
1560
|
+
const warnings = [];
|
|
1561
|
+
if(entries.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
|
|
1562
|
+
const never = entries.filter(e => e.enabled !== false && !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
|
|
1563
|
+
if(never.length) warnings.push(never.length + " 条非 constant 条目没有 keys,永远触发不了:" + never.map(e=>String(e.name||"")).join("、"));
|
|
1564
|
+
const res = { ok:true, simulated: results.length, results,
|
|
1565
|
+
lorebookTokens: lorebookTokens(entries), scanDepth: book.scan_depth,
|
|
1566
|
+
message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token)").join(";") };
|
|
1567
|
+
if(warnings.length) res.warnings = warnings;
|
|
1568
|
+
return res;
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
// 【静态体检】不需要测试句的那一半检查
|
|
1573
|
+
tool({
|
|
1574
|
+
name: "cc_lint_lorebook",
|
|
1575
|
+
description: "【世界书·静态检查】体检世界书:空 keys、只有维度名的 keys、keys 与 content 无交集、触发词重复、constant 数量、@@decorator 缺失、token 预算占用、**最坏情况单次触发量**(一句话命中多个条目时的 token 爆炸 —— 这一项调大 token_budget 盖不住)。写完世界书后调一次,比肉眼推理 keys 命中率靠谱。",
|
|
1576
|
+
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
1577
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1578
|
+
async execute(args, exec){
|
|
1579
|
+
const cur = getDraft(ctx, exec);
|
|
1580
|
+
const book = cur.data.character_book || {};
|
|
1581
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1582
|
+
const issues = [];
|
|
1583
|
+
const add = (level, message, names) => issues.push({ level, message, entries: names || [] });
|
|
1584
|
+
const isDimKey = k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k));
|
|
1585
|
+
const noKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
|
|
1586
|
+
if(noKeys.length) add("error", noKeys.length + " 条非 constant 条目没有 keys(永远触发不了)", noKeys.map(e=>String(e.name||"")));
|
|
1587
|
+
const dimOnly = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && e.keys.every(isDimKey));
|
|
1588
|
+
if(dimOnly.length) add("error", dimOnly.length + " 条条目的 keys 只有维度名,真实对话不会命中(可用 cc_simulate_trigger 验证)", dimOnly.map(e=>String(e.name||"")));
|
|
1589
|
+
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));
|
|
1590
|
+
if(notInContent.length) add("warn", notInContent.length + " 条条目的 keys 在自身 content 里找不到(可能误填或内容已改)", notInContent.map(e=>String(e.name||"")));
|
|
1591
|
+
const seen = new Map();
|
|
1592
|
+
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||""))); }));
|
|
1593
|
+
const dup = [...seen.entries()].filter(pair => pair[1].length > 1);
|
|
1594
|
+
if(dup.length) add("warn", dup.length + " 个触发词被多条条目共用(会同时触发,可能互相打架)", dup.map(pair => pair[0]));
|
|
1595
|
+
if(entries.length && !entries.some(e=>e.constant)) add("warn", "没有 constant 常驻条目:世界观基调只在命中触发词时才出现");
|
|
1596
|
+
if(entries.length && entries.every(e=> !String(e.content||"").includes("@@"))) add("warn", "所有条目都没有 @@decorator(@@position/@@depth/@@activate),插入位置全用默认值");
|
|
1597
|
+
const used = lorebookTokens(entries);
|
|
1598
|
+
const budget = Number(book.token_budget||0) || 0;
|
|
1599
|
+
if(budget>0 && used>budget) add("warn", "估算 " + used + " token,已超出 token_budget=" + budget);
|
|
1600
|
+
// N2(0.7.0):总量与「单次触发」是两个独立风险,只报总量会让「调大预算」看起来像修好了。
|
|
1601
|
+
const worst = worstCaseTrigger(entries);
|
|
1602
|
+
const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries, overBudget: budget > 0 && worst.tokens > budget };
|
|
1603
|
+
if(worstCase.overBudget){
|
|
1604
|
+
add("warn", "最坏情况单次触发约 " + worst.tokens + " token(含常驻 " + worst.constantTokens + ",预算 " + budget + "):「" + worst.entries.join(" + ") + "」一句话就吃光整份预算(调大 token_budget 盖不住这条)。", worst.entries);
|
|
1605
|
+
}
|
|
1606
|
+
if(!String(book.name||"").trim()) add("warn", "世界书没有名字(character_book.name 为空,导出后是一本无名的书)");
|
|
1607
|
+
const errorCount = issues.filter(i => i.level === "error").length;
|
|
1608
|
+
return { ok:true,
|
|
1609
|
+
entries: entries.length, manualEntries: entries.filter(e => !isAutoEntry(e)).length,
|
|
1610
|
+
constantEntries: entries.filter(e => e.constant).length,
|
|
1611
|
+
lorebookTokens: used, tokenBudget: budget, worstCase,
|
|
1612
|
+
issues, errorCount, warningCount: issues.length - errorCount,
|
|
1613
|
+
message: issues.length
|
|
1614
|
+
? "体检发现 " + issues.length + " 个问题(" + errorCount + " 个会真的不工作):" + issues.map(i=>i.message).join(";")
|
|
1615
|
+
: "世界书体检通过:没有发现结构性问题。" };
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
// ——————————————————————————————————————————————————————————————
|
|
1620
|
+
// 0.8.0 新增工具:人物关系图谱(4 个)
|
|
1621
|
+
// 关系数据活在插件侧(<DSH_HOME>/cc-groups/),**不写进 CCv3 任何字段**;要让分享出去的卡
|
|
1622
|
+
// 自包含,就用 cc_render_relations 把关系渲染进 description 的固定标记区。
|
|
1623
|
+
// 分组本身由用户在工坊右侧「已存角色」里创建与维护(「同一世界观拉进同一组」),工具侧只读写关系。
|
|
1624
|
+
// ——————————————————————————————————————————————————————————————
|
|
1625
|
+
|
|
1626
|
+
tool({
|
|
1627
|
+
name: "cc_get_relations",
|
|
1628
|
+
description: "【关系·读】查看当前卡所在分组的人物关系网(三维:方向 from→to / 类型 type / 强度 strength 1-5)。默认只回与当前卡相关的边(省上下文)。关系存在插件侧、不写进卡;卡还没入库或没入组时会明确告知并列出现有分组 —— 别硬猜分组 id。改关系用 cc_patch_relations,写进卡里用 cc_render_relations。",
|
|
1629
|
+
parameters: {
|
|
1630
|
+
type:"object",
|
|
1631
|
+
properties:{
|
|
1632
|
+
groupId:{ type:"string", description:"可选:显式指定分组 id(默认用当前卡所属分组)" },
|
|
1633
|
+
scope:{ type:"string", enum:["mine","all"], description:"mine(默认)= 只回与当前卡相关的边;all = 回整组节点与边" },
|
|
1634
|
+
includeNodes:{ type:"boolean", description:"是否附带节点清单(默认 true)" },
|
|
1635
|
+
},
|
|
1636
|
+
additionalProperties:false
|
|
1637
|
+
},
|
|
1638
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1639
|
+
async execute(args, exec){
|
|
1640
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1641
|
+
if(!r.ok){
|
|
1642
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1643
|
+
message: (r.cardId ? "这张卡(" + r.cardId + ")还没有加入任何分组。" : "当前草稿还没有关联已存卡(没有来源 id)。")
|
|
1644
|
+
+ "关系网按分组组织:请让用户在工坊右侧「已存角色」里新建分组、把同一世界观的卡拉进同一组,然后重试。现有分组:"
|
|
1645
|
+
+ (r.groups.length ? r.groups.map(g => g.name + "(" + g.id + "," + g.memberCount + " 卡 / " + g.edgeCount + " 关系)").join(";") : "(暂无)") };
|
|
1646
|
+
}
|
|
1647
|
+
const g = r.group, myNode = r.myNode;
|
|
1648
|
+
const scope = (args.scope === "all") ? "all" : "mine";
|
|
1649
|
+
const edges = (scope === "all" || !myNode) ? g.edges.slice() : g.edges.filter(e => e.from === myNode.id || e.to === myNode.id);
|
|
1650
|
+
const needIds = new Set();
|
|
1651
|
+
edges.forEach(e => { needIds.add(e.from); needIds.add(e.to); });
|
|
1652
|
+
if(myNode) needIds.add(myNode.id);
|
|
1653
|
+
const nodes = g.nodes.filter(n => scope === "all" || needIds.has(n.id) || n.kind === "card");
|
|
1654
|
+
const res = { ok:true,
|
|
1655
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1656
|
+
cardId: r.cardId || null, myNodeId: myNode ? myNode.id : null, scope,
|
|
1657
|
+
edges: edges.map(e => ({
|
|
1658
|
+
id: e.id, from: e.from, to: e.to,
|
|
1659
|
+
fromName: nameOfNode(g.nodes, e.from), toName: nameOfNode(g.nodes, e.to),
|
|
1660
|
+
type: e.type, typeLabel: REL_TYPE_LABELS[e.type] || e.type, label: e.label || "",
|
|
1661
|
+
strength: e.strength, direction: e.direction, note: e.note,
|
|
1662
|
+
mine: myNode ? (e.from === myNode.id ? "out" : (e.to === myNode.id ? "in" : null)) : null,
|
|
1663
|
+
})),
|
|
1664
|
+
groups: r.groups,
|
|
1665
|
+
message: "分组「" + g.name + "」共 " + g.nodes.length + " 个节点 / " + g.edges.length + " 条关系"
|
|
1666
|
+
+ (scope === "mine" ? ";与当前卡相关 " + edges.length + " 条。" : "。") };
|
|
1667
|
+
if(args.includeNodes !== false) res.nodes = nodes.map(n => ({ id:n.id, kind:n.kind, cardId:n.cardId, name:n.name, aliases:n.aliases, orphan: n.kind === "card" && !n.cardId }));
|
|
1668
|
+
return res;
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
tool({
|
|
1673
|
+
name: "cc_patch_relations",
|
|
1674
|
+
description: "【关系·改】增删改人物关系(三维:方向 from→to / 类型 type / 强度 strength)。ops 是动作数组:{op:\"addNode\",kind:\"npc\"|\"card\",cardId?,name,aliases?} / {op:\"updateNode\",nodeId,name?,aliases?} / {op:\"removeNode\",nodeId,removeEdges?} / {op:\"addEdge\",from,to,type,strength,direction,note?} / {op:\"updateEdge\",edgeId,同 addEdge 的字段} / {op:\"removeEdge\",edgeId}。type 取值 family/romance/mentor/friend/colleague/rival/enemy/debt/superior/other;direction:mutual=对等(一条边即可)|one-way=不对称(A→B 与 B→A 可以不同,这正是「三维」的价值)。**只改插件侧图谱**,要进卡请再调 cc_render_relations。删节点/删边是破坏性操作,先与用户确认。",
|
|
1675
|
+
parameters: {
|
|
1676
|
+
type:"object",
|
|
1677
|
+
properties:{
|
|
1678
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
1679
|
+
ops:{ type:"array", items:{ type:"object", additionalProperties:true }, description:"动作数组,逐项见工具说明" },
|
|
1680
|
+
expectedGroupRev:{ type:"number", description:"可选乐观锁:传上次 cc_get_relations 返回的 group.rev。**注意与 expectedRev 不同** —— expectedRev 是草稿的版本号(包装层统一校验),这里校验的是**分组图谱**的版本" },
|
|
1681
|
+
},
|
|
1682
|
+
required:["ops"],
|
|
1683
|
+
additionalProperties:false
|
|
1684
|
+
},
|
|
1685
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1686
|
+
async execute(args, exec){
|
|
1687
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1688
|
+
if(!r.ok){
|
|
1689
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1690
|
+
message: "这张卡还没入库或没加入分组,关系无处可放。请让用户先在工坊右侧「已存角色」里建组并把它拉进去。" };
|
|
1691
|
+
}
|
|
1692
|
+
const g = r.group;
|
|
1693
|
+
// ⚠️ 分组 rev 与草稿 rev 是两件事:expectedGroupRev 校验**图谱**,expectedRev(包装层)校验**草稿**。
|
|
1694
|
+
// 曾经两者同名,结果传分组 rev 会被包装层当成草稿 rev 拒掉(且错误文案说「草稿已被外部修改」,极具误导性)。
|
|
1695
|
+
if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
|
|
1696
|
+
return { ok:false, staleRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
|
|
1697
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1698
|
+
error: "分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重新读一遍再改。" };
|
|
1699
|
+
}
|
|
1700
|
+
const applied = applyGroupOps(g, args.ops);
|
|
1701
|
+
if(!applied.applied.length){
|
|
1702
|
+
return { ok:false, error:"没有任何 op 生效(原因见 rejected)", rejected: applied.rejected, group: { id: g.id, name: g.name, rev: g.rev } };
|
|
1703
|
+
}
|
|
1704
|
+
const saved = await writeGroup(applied.group);
|
|
1705
|
+
return { ok:true, applied: applied.applied, rejected: applied.rejected,
|
|
1706
|
+
group: { id: saved.id, name: saved.name, rev: saved.rev },
|
|
1707
|
+
nodes: saved.nodes.map(n => ({ id:n.id, kind:n.kind, name:n.name, cardId:n.cardId, aliases:n.aliases })),
|
|
1708
|
+
edges: saved.edges.map(e => ({ id:e.id, from:e.from, to:e.to, type:e.type, strength:e.strength, direction:e.direction, note:e.note })),
|
|
1709
|
+
message: "已改 " + applied.applied.length + " 项"
|
|
1710
|
+
+ (applied.rejected.length ? "(" + applied.rejected.length + " 项被拒:" + applied.rejected.map(x => x.error).join(";") + ")" : "")
|
|
1711
|
+
+ "。图谱 rev=" + saved.rev + ";把关系写进卡里请再调 cc_render_relations,并 cc_save_to_library 落库。" };
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
tool({
|
|
1716
|
+
name: "cc_render_relations",
|
|
1717
|
+
description: "【关系·渲染】把当前卡在图谱里的关系渲染进 description 的固定标记区(<!-- cc-relations:begin --> … <!-- cc-relations:end -->),让**导出的卡自包含** —— 别人拿到卡就能看到这些关系,不依赖插件数据。默认只写「我对别人的看法」(出边);includeIncoming:true 时追加一节「他们眼中的我」。dryRun:true 只回预览不改动。渲染后要 cc_save_to_library 才落库;重复渲染是**替换**,不会越写越长。",
|
|
1718
|
+
parameters: {
|
|
1719
|
+
type:"object",
|
|
1720
|
+
properties:{
|
|
1721
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
1722
|
+
includeIncoming:{ type:"boolean", description:"是否额外渲染「他们眼中的我」(默认 false)" },
|
|
1723
|
+
dryRun:{ type:"boolean", description:"true = 只回预览、不改草稿" },
|
|
1724
|
+
expectedGroupRev:{ type:"number", description:"可选乐观锁:**分组** rev 不符则拒绝(与草稿的 expectedRev 不同)" },
|
|
1725
|
+
},
|
|
1726
|
+
additionalProperties:false
|
|
1727
|
+
},
|
|
1728
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1729
|
+
async execute(args, exec){
|
|
1730
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1731
|
+
if(!r.ok){
|
|
1732
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1733
|
+
message: "这张卡还没入库或没加入分组,没有关系可渲染。" };
|
|
1734
|
+
}
|
|
1735
|
+
const g = r.group;
|
|
1736
|
+
if(!r.myNode){
|
|
1737
|
+
return { ok:false, error:"分组「" + g.name + "」里没有这张卡的节点:先在工坊关系视图里把它加入分组(或用 cc_patch_relations 加一个 kind:\"card\" 的节点)。" };
|
|
1738
|
+
}
|
|
1739
|
+
if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
|
|
1740
|
+
return { ok:false, staleRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
|
|
1741
|
+
error:"分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重读再渲染。" };
|
|
1742
|
+
}
|
|
1743
|
+
const section = renderRelationsSection(r.myNode.id, g.nodes, g.edges, { includeIncoming: args.includeIncoming === true });
|
|
1744
|
+
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
1745
|
+
const before = String((cur.data && cur.data.description) || "");
|
|
1746
|
+
const after = spliceRelations(before, section, "rev=" + g.rev + " group=" + g.id);
|
|
1747
|
+
const changed = before !== after;
|
|
1748
|
+
const outgoing = g.edges.filter(e => e.from === r.myNode.id).length;
|
|
1749
|
+
if(args.dryRun === true){
|
|
1750
|
+
return { ok:true, dryRun:true, changed, outgoing, section, preview: after,
|
|
1751
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1752
|
+
message: "预览:将写入 " + outgoing + " 条关系(" + (changed ? "description 会变化" : "与现状一致,无需重写") + ")。" };
|
|
1753
|
+
}
|
|
1754
|
+
if(changed){
|
|
1755
|
+
// ⚠️ 这一行曾经漏掉:changed 算出来了、setDraft 也调了,但 description 还是旧值 —— 表现是
|
|
1756
|
+
// 「返回 changed:true 而卡里什么都没变」,而且不好看出来(rev 照样递增)。
|
|
1757
|
+
cur.data.description = after;
|
|
1758
|
+
setDraft(ctx, cur, exec);
|
|
1759
|
+
}
|
|
1760
|
+
return { ok:true, changed, changedFields: changed ? ["description"] : [], outgoing, description: after,
|
|
1761
|
+
group: { id: g.id, name: g.name, rev: g.rev }, workflow: workflowStatus(cur),
|
|
1762
|
+
message: (changed ? "已把 " + outgoing + " 条关系写进 description 的【关系】段" : "description 里已经是最新关系,无需改动")
|
|
1763
|
+
+ "。注意:这只改了**当前草稿**,要落库请再调 cc_save_to_library(或让用户在工坊点「保存当前」)。" };
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
tool({
|
|
1768
|
+
name: "cc_group_check",
|
|
1769
|
+
description: "【关系·体检】跨卡检查一个分组:孤儿节点(卡已删除)、悬空关系、重名节点(渲染会指向同一个人)、未渲染/已过期的【关系】段,以及**群聊每轮固定开销**(所有成员卡 constant 条目之和 vs 最小预算 —— 群聊时卡越多这块钱越贵,一张卡的单卡体检看不出来)。做完一批群聊卡后调一次。",
|
|
1770
|
+
parameters: {
|
|
1771
|
+
type:"object",
|
|
1772
|
+
properties:{ groupId:{ type:"string", description:"可选:默认用当前卡所属分组" } },
|
|
1773
|
+
additionalProperties:false
|
|
1774
|
+
},
|
|
1775
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1776
|
+
async execute(args, exec){
|
|
1777
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1778
|
+
if(!r.ok){
|
|
1779
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1780
|
+
message: "这张卡还没入库或没加入分组,没有可体检的对象。" };
|
|
1781
|
+
}
|
|
1782
|
+
const g = r.group;
|
|
1783
|
+
const memberIds = await groupMemberIds(g.id);
|
|
1784
|
+
const cards = await libraryCardsFor(memberIds);
|
|
1785
|
+
const res = checkGroup(g, memberIds, cards);
|
|
1786
|
+
const errorCount = res.issues.filter(i => i.level === "error").length;
|
|
1787
|
+
return { ok:true,
|
|
1788
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1789
|
+
issues: res.issues, stats: res.stats,
|
|
1790
|
+
errorCount, warningCount: res.issues.length - errorCount,
|
|
1791
|
+
members: cards.map(c => ({ id: c.id, name: c.name })),
|
|
1792
|
+
message: res.issues.length
|
|
1793
|
+
? "分组「" + g.name + "」发现 " + res.issues.length + " 个问题(" + errorCount + " 个会真的不工作):" + res.issues.map(i => i.message).join(";")
|
|
1794
|
+
: "分组「" + g.name + "」体检通过:" + res.stats.members + " 张成员卡 / " + res.stats.edges + " 条关系,没有结构性问题。" };
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
// 【导出干跑】补上「五维到底导不导出」这个此前没人能回答的问题
|
|
1799
|
+
tool({
|
|
1800
|
+
name: "cc_preview_export",
|
|
1801
|
+
description: "【导出·干跑】不落盘地预估导出结果:JSON 体积、各字段估算 token、最终会导出的字段清单(并回答「五维会不会一起导出」)、五维与世界书条目的重复内容量。导出前调一次,心里有数。",
|
|
1802
|
+
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
1803
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1804
|
+
async execute(args, exec){
|
|
1805
|
+
const cur = getDraft(ctx, exec);
|
|
1806
|
+
const d = cur.data;
|
|
1807
|
+
const book = d.character_book || {};
|
|
1808
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1809
|
+
const json = JSON.stringify(cur, null, 2);
|
|
1810
|
+
const w = (d.extensions && d.extensions.cc_world) || {};
|
|
1811
|
+
// 五维(extensions.cc_world)会被一起导出,且与 autoLorebook 生成的条目内容逐字重复 —— 量化给出
|
|
1812
|
+
let dupChars = 0;
|
|
1813
|
+
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; }); });
|
|
1814
|
+
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"];
|
|
1815
|
+
const bytes = Buffer.byteLength(json, "utf8");
|
|
1816
|
+
return { ok:true,
|
|
1817
|
+
bytes, tokens: estimateTokens(json), lorebookTokens: lorebookTokens(entries),
|
|
1818
|
+
exportedFields, extensionsExported: Object.keys(d.extensions || {}),
|
|
1819
|
+
worldDuplicatedChars: dupChars,
|
|
1820
|
+
loreCount: entries.length, manualLoreCount: entries.filter(e => !isAutoEntry(e)).length,
|
|
1821
|
+
message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中世界书 " + lorebookTokens(entries) + " token;五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
|
|
622
1822
|
}
|
|
623
1823
|
});
|
|
624
1824
|
|
|
625
|
-
|
|
1825
|
+
tool({
|
|
626
1826
|
name: "cc_validate",
|
|
627
1827
|
description: "【工作流第6步·收口必调】校验 CC 角色卡(SPEC_V3 + 角色四件套/五维≥3/世界书≥5/问候语齐)。valid=false 按 errors 与用户讨论继续补;valid=true 时总结成果并邀请用户在工坊微调后“导出 JSON”或“★ 保存到侧边栏”。全程强调共创:多问用户、多给草案供挑选。",
|
|
628
1828
|
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
@@ -631,27 +1831,70 @@ function apply(ctx){
|
|
|
631
1831
|
const d = getDraft(ctx, exec);
|
|
632
1832
|
const k = keyFor(ctx, exec);
|
|
633
1833
|
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
1834
|
const wf = workflowStatus(d);
|
|
1835
|
+
const book = d.data.character_book || {};
|
|
1836
|
+
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
638
1837
|
const errors=[], warnings=[];
|
|
1838
|
+
// —— 结构(不齐 = 失败)——
|
|
639
1839
|
if(!String(d.data.name||"").trim()) errors.push("缺 data.name(角色名)");
|
|
640
1840
|
if(!String(d.data.description||"").trim()) errors.push("缺 data.description");
|
|
641
1841
|
if(!String(d.data.personality||"").trim()) errors.push("缺 data.personality");
|
|
642
|
-
if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充");
|
|
643
1842
|
if(!Array.isArray(d.data.group_only_greetings) || d.data.group_only_greetings.length<1) errors.push("缺 group_only_greetings(至少 1 条必填)");
|
|
644
|
-
if(
|
|
1843
|
+
if(wf.state.world !== "ok") errors.push("五维世界观未齐:需 timeline/factions/geo/power/daily 至少 3 项(当前 "+wf.worldFilled+"/5),请调 cc_patch_world");
|
|
1844
|
+
if(wf.state.lore !== "ok"){
|
|
1845
|
+
errors.push(wf.manualLoreCount === 0
|
|
1846
|
+
? "世界书只有自动条目("+wf.loreCount+" 条,全部由 autoLorebook 生成),未完成共创:请调 cc_add_lorebook_entries 至少补 1 条(总≥5 条)"
|
|
1847
|
+
: "世界书未齐:需至少 5 条(当前 "+wf.loreCount+" 条),请调 cc_add_lorebook_entries 补到 5 条");
|
|
1848
|
+
}
|
|
1849
|
+
// —— 质量(P1-5:此前 valid=true 只代表「四件套非空」,给用户「卡没问题了」的错觉)——
|
|
1850
|
+
if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充(缺失时模型只能靠 description 猜场景)");
|
|
1851
|
+
if(!Array.isArray(d.data.alternate_greetings) || d.data.alternate_greetings.length<2) warnings.push("alternate_greetings 建议至少 2 条(当前 "+((d.data.alternate_greetings||[]).length)+" 条)");
|
|
645
1852
|
if(!String(d.data.first_mes||"").trim()) warnings.push("first_mes 为空");
|
|
646
|
-
if(!
|
|
647
|
-
if(!
|
|
648
|
-
if(
|
|
649
|
-
if(
|
|
1853
|
+
if(!String(d.data.mes_example||"").trim()) warnings.push("mes_example 为空,建议写几段示例对话");
|
|
1854
|
+
if(!String(d.data.creator||"").trim()) warnings.push("data.creator 为空(导出后别人看不到作者)—— 用 cc_patch_character 的 creator 参数写入,或让用户在工坊「角色基础」里手动填。");
|
|
1855
|
+
if(!String(book.name||"").trim()) warnings.push("世界书没有名字(character_book.name 为空),导出后是一本无名的书;可用 cc_patch_book_meta 命名");
|
|
1856
|
+
if(!String(book.description||"").trim()) warnings.push("character_book.description 为空,建议一句话说明这本世界书的作用");
|
|
1857
|
+
if(entries.length>0 && !entries.some(e=>e.constant)) warnings.push("世界书建议至少 1 条 constant 常驻条目(否则全靠触发词命中)");
|
|
1858
|
+
if(entries.length>0 && entries.every(e=> !String(e.content||"").includes("@@"))) warnings.push("世界书条目建议带 @@decorator(@@position/@@depth/@@activate)");
|
|
1859
|
+
const deadKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length===0));
|
|
1860
|
+
if(deadKeys.length) warnings.push(deadKeys.length + " 条非 constant 条目没有 keys(永远触发不了):" + deadKeys.map(e=>String(e.name||"未命名")).join("、"));
|
|
1861
|
+
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))));
|
|
1862
|
+
if(dimOnly.length) warnings.push(dimOnly.length + " 条条目的 keys 只有维度名(" + dimOnly.map(e=>String(e.name||"未命名")).join("、") + "),真实对话不会命中;可用 cc_simulate_trigger 拿一句用户会说的话验证,建议改成实体名");
|
|
1863
|
+
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); });
|
|
1864
|
+
if(keyless.length) warnings.push(keyless.length + " 条条目的 keys 在自身 content 里找不到(可能是误填):" + keyless.map(e=>String(e.name||"未命名")).join("、"));
|
|
1865
|
+
const used = lorebookTokens(entries);
|
|
1866
|
+
const budget = Number(book.token_budget||0) || 0;
|
|
1867
|
+
if(budget>0 && used > budget) warnings.push("世界书内容估算约 "+used+" token,已超出 token_budget="+budget+",建议调大预算或精简条目(cc_patch_book_meta)");
|
|
1868
|
+
// —— 终态:校验通过后 next 不再指向 cc_validate(P0-3:那正是弱模型死循环的诱因)——
|
|
650
1869
|
const valid = errors.length===0;
|
|
651
|
-
const
|
|
652
|
-
|
|
1870
|
+
const next = valid
|
|
1871
|
+
? "校验通过 ✓ 工作流已完成(step 7/7)。请在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘到 ~/.dsh/cc-library;还想打磨就直接说改哪里。"
|
|
1872
|
+
: "校验未通过,按 errors 逐项补齐(严禁跳步),第一项:" + errors[0];
|
|
1873
|
+
// R1(0.6.0):终态的 `hint` 必须与 `next` 同源 —— 否则会出现「next 说工作流已完成、hint 说下一步必须去校验」
|
|
1874
|
+
// 这种自相矛盾(弱模型会读信息最全的 hint,于是反复调 cc_validate)。半修的状态机比没修更危险。
|
|
1875
|
+
const wfDone = Object.assign({}, wf, {
|
|
1876
|
+
step: valid ? 7 : wf.step,
|
|
1877
|
+
done: valid,
|
|
1878
|
+
readyToValidate: !valid && wf.readyToValidate,
|
|
1879
|
+
next,
|
|
1880
|
+
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid),
|
|
1881
|
+
});
|
|
1882
|
+
const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
|
|
1883
|
+
return { ok:true, valid, done: valid, errors, warnings: warningsAll,
|
|
1884
|
+
loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
|
|
1885
|
+
lorebookTokens: used, tokenBudget: budget,
|
|
1886
|
+
draftStatus: st, workflow: wfDone,
|
|
1887
|
+
// R3(0.6.0):message 不再拼接 next —— 那会让「校验通过 ✓」连说两遍(两个来源拼接未去重)。
|
|
1888
|
+
// 下一步只保留 workflow.next 一处。
|
|
1889
|
+
message: valid
|
|
1890
|
+
? "校验通过 ✓ 工作流完成(世界书 " + wf.loreCount + " 条 / 估算 " + used + " token)。"
|
|
1891
|
+
: "校验未通过:" + errors.length + " 项待补。" };
|
|
653
1892
|
}
|
|
654
1893
|
});
|
|
655
1894
|
}
|
|
656
1895
|
|
|
657
|
-
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots
|
|
1896
|
+
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
1897
|
+
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|
|
1898
|
+
groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
1899
|
+
renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, groupMemberIds, libraryCardsFor,
|
|
1900
|
+
REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
|