@xia-sc/dsh-cc-studio 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/lib/agent.js +516 -109
- package/lib/client.js +272 -31
- package/lib/index.js +279 -56
- package/package.json +2 -2
package/lib/agent.js
CHANGED
|
@@ -12,7 +12,7 @@ const name = "dsh-cc-studio-agent";
|
|
|
12
12
|
// 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
|
|
13
13
|
// 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
|
|
14
14
|
// 发版时与 package.json 的 version 同步。
|
|
15
|
-
const BUILD = "0.
|
|
15
|
+
const BUILD = "0.9.1";
|
|
16
16
|
const inject = ["tools", "agents"];
|
|
17
17
|
|
|
18
18
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
@@ -525,7 +525,7 @@ function spliceRelations(description, section, meta){
|
|
|
525
525
|
const head = desc.replace(/\s*$/, "");
|
|
526
526
|
return (head + (head ? "\n\n" : "") + block).trim();
|
|
527
527
|
}
|
|
528
|
-
function checkGroup(group, members, cards){
|
|
528
|
+
function checkGroup(group, members, cards, sharedEntries){
|
|
529
529
|
const g = normalizeGroup(group);
|
|
530
530
|
const issues = [];
|
|
531
531
|
const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
|
|
@@ -557,12 +557,21 @@ function checkGroup(group, members, cards){
|
|
|
557
557
|
});
|
|
558
558
|
if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
|
|
559
559
|
if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
|
|
560
|
-
const
|
|
560
|
+
const cardConst = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
|
|
561
|
+
// 0.9.0 报告:共享世界书的 constant 条目在群聊里**同样每轮插入**(独立一本、由客户端叠加),
|
|
562
|
+
// 所以「群聊每轮固定开销」必须是 卡内 + 共享 的合计 —— 只报卡内会低报一半。
|
|
563
|
+
const sharedConst = (Array.isArray(sharedEntries) ? sharedEntries : [])
|
|
564
|
+
.filter(e => e && e.enabled !== false && e.constant)
|
|
565
|
+
.reduce((n, e) => n + estimateTokensLite(e && e.content), 0);
|
|
566
|
+
const constTokens = cardConst + sharedConst;
|
|
561
567
|
const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
|
|
562
|
-
if(memberCards.length > 1 && constTokens > minBudget * 0.8){
|
|
563
|
-
add("warn", "constant-budget", "
|
|
568
|
+
if((memberCards.length > 1 || sharedConst > 0) && constTokens > minBudget * 0.8){
|
|
569
|
+
add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻 " + cardConst
|
|
570
|
+
+ (sharedConst ? " + 共享书常驻 " + sharedConst : "") + " = 合计约 " + constTokens
|
|
571
|
+
+ " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
|
|
564
572
|
}
|
|
565
|
-
return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
|
|
573
|
+
return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
|
|
574
|
+
constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget } };
|
|
566
575
|
}
|
|
567
576
|
async function readGroupRaw(id){
|
|
568
577
|
const file = safeGroupId(id) + ".json";
|
|
@@ -867,20 +876,52 @@ function looksLikeLegacyAuto(e){
|
|
|
867
876
|
if(!e || !AUTO_NAMES.has(String(e.name||""))) return false;
|
|
868
877
|
return /^\s*@@/.test(String(e.content||"")) || Array.isArray(e.keys) && e.keys.some(k => ["timeline","faction","geography","power","daily"].includes(String(k)));
|
|
869
878
|
}
|
|
870
|
-
|
|
879
|
+
/**
|
|
880
|
+
* 工作流状态。第二个参数是**该组共享世界书**(`{ bookName, entries, tokens }`,没有就传 null/省略)。
|
|
881
|
+
*
|
|
882
|
+
* 第二轮报告 P0:共享书把一部分世界设定挪到了**组**上,而「≥5 条且至少 1 条共创」是**收尾判定** ——
|
|
883
|
+
* 只数卡内的话,「按推荐做法去重」的卡(重复条目提升进共享书、卡里只剩私密条目)反而永远判不合格:
|
|
884
|
+
* 用户完全照最佳实践做,系统判他不通过,只剩两条路(忍受失败 / 把冗余抄回卡里 —— 后者等于取消共享书)。
|
|
885
|
+
* 所以**达标判定走合计口径**,同时把卡内/共享两个数分开报出来,谁都不必去猜:
|
|
886
|
+
* loreCount / manualLoreCount = 卡内(写类工具的文案仍按它说,才不会被误读)
|
|
887
|
+
* sharedLoreCount / sharedManualCount = 共享书
|
|
888
|
+
* loreTotal / manualLoreTotal = 合计(`state.lore` 与 `readyToValidate` 认它)
|
|
889
|
+
*/
|
|
890
|
+
function workflowStatus(draft, shared){
|
|
871
891
|
const d = draft && draft.data ? draft.data : {};
|
|
872
892
|
const w = (d.extensions && d.extensions.cc_world) ? d.extensions.cc_world : {};
|
|
873
893
|
const entries = (d.character_book && Array.isArray(d.character_book.entries)) ? d.character_book.entries : [];
|
|
874
894
|
const loreCount = entries.length;
|
|
875
895
|
const manualLoreCount = entries.filter(e => !isAutoEntry(e)).length;
|
|
896
|
+
const sharedEntries = (shared && Array.isArray(shared.entries)) ? shared.entries : [];
|
|
897
|
+
const sharedLoreCount = sharedEntries.length;
|
|
898
|
+
const sharedManualCount = sharedEntries.filter(e => !isAutoEntry(e)).length;
|
|
899
|
+
const loreTotal = loreCount + sharedLoreCount;
|
|
900
|
+
const manualLoreTotal = manualLoreCount + sharedManualCount;
|
|
876
901
|
const worldFilled = WORLD_DIMS.filter(k => typeof w[k]==="string" && String(w[k]).trim().length>=6).length;
|
|
902
|
+
// 一行「世界书多少条」的说法:卡内 8 条就说「8 条」,卡内 3 + 共享 5 就说「8 条 = 卡内 3 + 共享书 5」。
|
|
903
|
+
const lorePhrase = sharedLoreCount
|
|
904
|
+
? loreTotal + " 条 = 卡内 " + loreCount + " + 共享书 " + sharedLoreCount
|
|
905
|
+
: loreTotal + " 条";
|
|
906
|
+
// 第三轮报告:`constant` / `@@decorator` 这两条是**关于整本世界书**的检查,口径必须与
|
|
907
|
+
// `cc_lint_lorebook` 一致(卡内 + 共享书)—— 只数卡内会让「卡里没有 constant、共享书里有」的卡
|
|
908
|
+
// 每次都收到一条无意义的提醒,而误报会被训练成「忽略 warning」。
|
|
909
|
+
// 范围:**存在性**检查(有没有 constant / 有没有 @@)走合计;**逐条质量**检查(死 keys /
|
|
910
|
+
// 只有维度名的 keys / keys 与正文无交集)在 validate 里**只看卡内** —— 这是**有意**的分工(第五轮报告
|
|
911
|
+
// 实测确认过):validate 是「这张卡」的收尾判定,列条目名不属于它。全量的逐条质量检查归
|
|
912
|
+
// `cc_lint_lorebook`(覆盖卡内 + 共享书,每条 issue 带 `source`),共享书自己那份由
|
|
913
|
+
// `cc_get_shared_book` 的 `lint` 段给出 —— 三处口径不同是分工,不是漏改(上一版这里曾把
|
|
914
|
+
// 「共享书走 cc_get_shared_book」写成事实,而那条线上当时并没有这个能力,这个错已修)。
|
|
915
|
+
const metrics = lorebookMetrics(entries, sharedEntries); // 三段基础统计:一处实现(见该函数注释)
|
|
916
|
+
const constantEntries = { card: metrics.card.constant, shared: metrics.shared.constant, total: metrics.total.constant };
|
|
917
|
+
const sharedHasDecorator = metrics.shared.hasDecorator;
|
|
877
918
|
|
|
878
919
|
const nameOk = !!String(d.name||"").trim();
|
|
879
920
|
const descOk = !!String(d.description||"").trim();
|
|
880
921
|
const characterState = (nameOk && descOk && String(d.personality||"").trim() && String(d.scenario||"").trim())
|
|
881
922
|
? "ok" : ((nameOk && descOk) ? "partial" : "missing");
|
|
882
923
|
const worldState = worldFilled >= 3 ? "ok" : (worldFilled > 0 ? "partial" : "missing");
|
|
883
|
-
const loreState = (
|
|
924
|
+
const loreState = (loreTotal >= 5 && manualLoreTotal >= 1) ? "ok" : (loreTotal > 0 ? "partial" : "missing");
|
|
884
925
|
const gFirst = !!String(d.first_mes||"").trim();
|
|
885
926
|
const gAlt = Array.isArray(d.alternate_greetings) ? d.alternate_greetings.length : 0;
|
|
886
927
|
const gGroup = Array.isArray(d.group_only_greetings) ? d.group_only_greetings.length : 0;
|
|
@@ -893,11 +934,11 @@ function workflowStatus(draft){
|
|
|
893
934
|
step = 3; next = "角色基础已齐,下一步必须 cc_patch_world:先与用户讨论世界观侧重(哪几维最想展开/有无私设)再补 5 维(≥3 项),**并为每一维自报触发词 loreKeys**(真实对话里用户会说的实体名,如 {geo:[\"旧校舍\",\"广播室\"]})——不给的话自动条目只能用维度名当 key,永远命中不了";
|
|
894
935
|
} else if(loreState !== "ok"){
|
|
895
936
|
step = 4;
|
|
896
|
-
next =
|
|
897
|
-
? "五维已齐,autoLorebook 已自动生成 "+
|
|
898
|
-
: "五维已齐,下一步必须 cc_add_lorebook_entries
|
|
937
|
+
next = manualLoreTotal === 0
|
|
938
|
+
? "五维已齐,autoLorebook 已自动生成 "+loreTotal+" 条基础条目("+lorePhrase+"),但它们以维度名当触发词、且不满足共创要求:下一步必须 cc_add_lorebook_entries,先与用户讨论触发词/常驻偏好,至少再补 1 条(合计≥5 条、至少 1 constant、content 头部写 @@decorator)"
|
|
939
|
+
: "五维已齐,下一步必须 cc_add_lorebook_entries:与用户讨论触发词/常驻偏好后补到合计≥5 条(年表/势力/地理/力量/日常各 1,至少 1 constant,@@decorator),当前 "+lorePhrase+"(其中共创 "+manualLoreTotal+" 条)";
|
|
899
940
|
} else if(greetingsState !== "ok"){
|
|
900
|
-
step = 5; next = "世界书已齐("+
|
|
941
|
+
step = 5; next = "世界书已齐("+lorePhrase+",其中共创 "+manualLoreTotal+" 条),下一步必须 cc_patch_greetings:先问用户偏好的开场场景/关系阶段/群聊与私聊区分,再补问候语(first_mes + alternate≥2 + group≥1)";
|
|
901
942
|
} else {
|
|
902
943
|
step = 6; next = "结构已齐,下一步必须 cc_validate 校验,valid=true 才可收尾;通过后提醒用户在工坊左下角「校验导出」入口打开导出页(JSON / PNG / CHARX),或点「★ 保存当前」落盘";
|
|
903
944
|
}
|
|
@@ -906,21 +947,34 @@ function workflowStatus(draft){
|
|
|
906
947
|
step,
|
|
907
948
|
readyToValidate: step === 6,
|
|
908
949
|
state,
|
|
909
|
-
worldFilled,
|
|
950
|
+
worldFilled,
|
|
951
|
+
loreCount, manualLoreCount, // 卡内
|
|
952
|
+
sharedLoreCount, sharedManualCount, // 共享书(没有就是 0)
|
|
953
|
+
loreTotal, manualLoreTotal, // 合计 —— `state.lore` 认的是这两个
|
|
954
|
+
sharedBook: sharedLoreCount ? { name: String((shared && shared.bookName) || ""), entries: sharedLoreCount, tokens: Number((shared && shared.tokens) || 0) } : null,
|
|
955
|
+
constantEntries, // { card, shared, total } —— 与 cc_lint_lorebook 同一口径
|
|
956
|
+
sharedHasDecorator, // 共享书里有没有 @@decorator(存在性检查用合计)
|
|
910
957
|
next,
|
|
911
958
|
// 0.6.0(R1/R2):`hint` 只报**进度**,不再内嵌 `next` 全文 ——
|
|
912
959
|
// 旧版 `hint = 进度 + " → " + next` 让同一句话在一份返回里出现三遍(hint 内嵌 next、message 又等于 hint)。
|
|
913
960
|
// 终态(校验通过)也走同一个函数,所以 `hint` 不会再说「下一步必须 cc_validate」(那是第一轮 P0-3 的半修残留)。
|
|
914
|
-
hint: progressHint(step, state, worldFilled, loreCount, manualLoreCount, false),
|
|
961
|
+
hint: progressHint(step, state, worldFilled, loreCount, manualLoreCount, false, sharedLoreCount),
|
|
915
962
|
};
|
|
916
963
|
}
|
|
964
|
+
/** 带共享书口径的工作流状态:**判定完成度**一律用它(纯纯汇报卡内条数的地方才继续用 workflowStatus)。
|
|
965
|
+
* 拿不到共享书(没有来源卡 / 没进组 / 该组还没建书)时,等价于只算卡内 —— 与旧行为完全一致。 */
|
|
966
|
+
async function workflowFor(ctx, exec, draft){
|
|
967
|
+
let shared = null;
|
|
968
|
+
try{ shared = await sharedBookScopeFor(ctx, exec); }catch(e){ shared = null; }
|
|
969
|
+
return workflowStatus(draft, shared);
|
|
970
|
+
}
|
|
917
971
|
/** 一行进度摘要(**不含 next** —— 下一步是 workflow.next 的职责,别再复制一份)。 */
|
|
918
|
-
function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done){
|
|
972
|
+
function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done, sharedLoreCount){
|
|
919
973
|
const mark = s => s==="ok" ? "✓" : (s==="partial" ? "◐" : "✗");
|
|
920
974
|
return "工作流 " + step + "/" + (done ? 7 : 6)
|
|
921
975
|
+ " " + mark(state.character) + "角色"
|
|
922
976
|
+ " " + mark(state.world) + "五维(" + worldFilled + "/5)"
|
|
923
|
-
+ " " + mark(state.lore) + "世界书(" + loreCount + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
|
|
977
|
+
+ " " + mark(state.lore) + "世界书(" + loreCount + (sharedLoreCount ? "+" + sharedLoreCount : "") + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
|
|
924
978
|
+ " " + mark(state.greetings) + "问候语"
|
|
925
979
|
+ (done ? " · 已完成,可导出/保存" : "");
|
|
926
980
|
}
|
|
@@ -935,6 +989,88 @@ function estimateTokens(text){
|
|
|
935
989
|
function lorebookTokens(entries){
|
|
936
990
|
return (Array.isArray(entries)? entries : []).reduce((n,e) => n + estimateTokens(e && e.content), 0);
|
|
937
991
|
}
|
|
992
|
+
/**
|
|
993
|
+
* 世界书的三段基础统计(**卡内 / 共享书 / 合计**)—— 一处实现,多处引用。
|
|
994
|
+
*
|
|
995
|
+
* 为什么收口(第四轮报告的教训):共享书的条目/token 口径已经散落在 simulate / lint /
|
|
996
|
+
* group_check / preview_export / validate 五个工具里,于是连着两轮出现「改了四个、漏了第五个」
|
|
997
|
+
* 与「validate 改了、lint 漏了」——都是同一个语义写了两遍。基础统计收在这里(同
|
|
998
|
+
* `outgoingEdgesOf()` 之于渲染与计数),各工具只在其上做自己的派生(最坏情况、单次触发、预算占用…)。
|
|
999
|
+
*
|
|
1000
|
+
* 同时给出两个**存在性**判断的合计口径:有没有 constant、有没有 `@@decorator` ——
|
|
1001
|
+
* 这两条是「整本世界书」的性质,只数卡内会让「卡内没有、共享书里有」的组合被误报。
|
|
1002
|
+
* ⚠️ **逐条质量**检查(死 keys / 只有维度名的 keys / keys 与正文无交集 / 触发词冲突)**不在这里**:
|
|
1003
|
+
* 那是各工具自己决定范围的事 —— `cc_lint_lorebook` 的整改清单面向「你正在编辑的这张卡」,
|
|
1004
|
+
* `cc_validate` 是卡的收尾判定,两者都只覆盖卡内条目(共享书的条目质量走 cc_get_shared_book 那条线)。
|
|
1005
|
+
*/
|
|
1006
|
+
function lorebookMetrics(cardEntries, sharedEntries){
|
|
1007
|
+
const card = Array.isArray(cardEntries) ? cardEntries : [];
|
|
1008
|
+
const shared = Array.isArray(sharedEntries) ? sharedEntries : [];
|
|
1009
|
+
const seg = (list) => ({
|
|
1010
|
+
entries: list.length,
|
|
1011
|
+
tokens: lorebookTokens(list),
|
|
1012
|
+
constant: list.filter(e => e && e.constant === true).length,
|
|
1013
|
+
constantTokens: list.filter(e => e && e.constant === true).reduce((n, e) => n + estimateTokens(e && e.content), 0),
|
|
1014
|
+
manual: list.filter(e => !isAutoEntry(e)).length,
|
|
1015
|
+
hasDecorator: list.some(e => String((e && e.content) || "").includes("@@")),
|
|
1016
|
+
});
|
|
1017
|
+
return { card: seg(card), shared: seg(shared), total: seg(card.concat(shared)), all: card.concat(shared) };
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* 世界书条目的**逐条质量检查** —— 一把尺子,卡内与共享书共用。
|
|
1021
|
+
*
|
|
1022
|
+
* 四类:非 constant 却没 keys / keys 只有维度名 / keys 与正文无交集 / 触发词被多条条目共用。
|
|
1023
|
+
*
|
|
1024
|
+
* 为什么收口(第五轮报告):这四类此前只写在 `cc_lint_lorebook` 里、只作用于**卡内**,当时的理由是
|
|
1025
|
+
* 「共享书条目的质量走 `cc_get_shared_book` 那条线」—— 可那条线上**根本没有这个能力**(只有重复检测),
|
|
1026
|
+
* 于是共享书成了没人体检的一格:往书里塞一条同时踩三类的坏条目,两个工具都不出声。
|
|
1027
|
+
* 而共享书恰恰**比卡内更需要主动报**:它多卡共用(一条死 key 在**所有**成员卡上都不生效)、
|
|
1028
|
+
* 又**没有「卡」这个载体**(用户不会顺手翻到它)。
|
|
1029
|
+
*
|
|
1030
|
+
* 用法:`auditLoreEntries([{ source:"card", entries }, { source:"shared", entries: sharedEntries }])`。
|
|
1031
|
+
* 触发词冲突**跨来源一起算** —— 同一个词同时命中卡内与共享书的两条,是真实存在的 token 叠加。
|
|
1032
|
+
* 每条 issue 带 `entries`(条目名,冲突类则是**冲突的触发词**)、`contentKeys`(便于直接定位改)、
|
|
1033
|
+
* 与 `source`(`card` / `shared` / `mixed`)。
|
|
1034
|
+
*/
|
|
1035
|
+
function auditLoreEntries(groups){
|
|
1036
|
+
const gs = (Array.isArray(groups) ? groups : []).map(g => ({
|
|
1037
|
+
source: String((g && g.source) || "card"),
|
|
1038
|
+
list: (g && Array.isArray(g.entries) ? g.entries : []).filter(e => e && e.enabled !== false),
|
|
1039
|
+
}));
|
|
1040
|
+
const all = gs.reduce((n, g) => n.concat(g.list), []);
|
|
1041
|
+
const sourceOf = (e) => { for(const g of gs){ if(g.list.indexOf(e) >= 0) return g.source; } return "card"; };
|
|
1042
|
+
const srcOf = (items) => { const out = []; items.forEach(e => { const s = sourceOf(e); if(out.indexOf(s) < 0) out.push(s); }); return out.length > 1 ? "mixed" : (out[0] || "card"); };
|
|
1043
|
+
const issues = [];
|
|
1044
|
+
const push = (level, code, message, items) => issues.push({ level, code, message,
|
|
1045
|
+
entries: items.map(e => String((e && e.name) || "")).filter(Boolean),
|
|
1046
|
+
contentKeys: items.map(contentKeyOf), source: srcOf(items) });
|
|
1047
|
+
const isDimKey = k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k));
|
|
1048
|
+
const nonConst = all.filter(e => !e.constant);
|
|
1049
|
+
const keyed = nonConst.filter(e => Array.isArray(e.keys) && e.keys.length > 0);
|
|
1050
|
+
const noKeys = nonConst.filter(e => !Array.isArray(e.keys) || e.keys.length === 0);
|
|
1051
|
+
if(noKeys.length) push("error", "no-keys", noKeys.length + " 条非 constant 条目没有 keys(永远触发不了)", noKeys);
|
|
1052
|
+
const dimOnly = keyed.filter(e => e.keys.every(isDimKey));
|
|
1053
|
+
if(dimOnly.length) push("error", "dim-only-keys", dimOnly.length + " 条条目的 keys 只有维度名,真实对话不会命中(可用 cc_simulate_trigger 验证)", dimOnly);
|
|
1054
|
+
const notInContent = keyed.filter(e => !e.keys.some(k => String(e.content || "").indexOf(String(k)) >= 0));
|
|
1055
|
+
if(notInContent.length) push("warn", "key-not-in-content", notInContent.length + " 条条目的 keys 在自身 content 里找不到(可能误填或内容已改)", notInContent);
|
|
1056
|
+
const seen = new Map();
|
|
1057
|
+
all.forEach(e => (Array.isArray(e.keys) ? e.keys : []).forEach(k => {
|
|
1058
|
+
const kk = String(k).toLowerCase();
|
|
1059
|
+
if(!seen.has(kk)) seen.set(kk, []);
|
|
1060
|
+
const arr = seen.get(kk);
|
|
1061
|
+
if(arr.indexOf(e) < 0) arr.push(e);
|
|
1062
|
+
}));
|
|
1063
|
+
const dup = [...seen.entries()].filter(p => p[1].length > 1);
|
|
1064
|
+
if(dup.length){
|
|
1065
|
+
const items = [];
|
|
1066
|
+
dup.forEach(p => p[1].forEach(e => { if(items.indexOf(e) < 0) items.push(e); }));
|
|
1067
|
+
issues.push({ level: "warn", code: "duplicate-keys",
|
|
1068
|
+
message: dup.length + " 个触发词被多条条目共用(会同时触发,可能互相打架)",
|
|
1069
|
+
entries: dup.map(p => p[0]), // 与既有行为一致:这一项的 entries 是**冲突的触发词**
|
|
1070
|
+
contentKeys: items.map(contentKeyOf), source: srcOf(items) });
|
|
1071
|
+
}
|
|
1072
|
+
return { issues, errorCount: issues.filter(i => i.level === "error").length };
|
|
1073
|
+
}
|
|
938
1074
|
/**
|
|
939
1075
|
* 最坏情况单次触发量(0.7.0,N2)。
|
|
940
1076
|
*
|
|
@@ -1063,7 +1199,7 @@ function apply(ctx){
|
|
|
1063
1199
|
const draft = getDraft(ctx, exec);
|
|
1064
1200
|
const k = keyFor(ctx, exec);
|
|
1065
1201
|
const st = draftStatusFor(k, draft);
|
|
1066
|
-
const wf =
|
|
1202
|
+
const wf = await workflowFor(ctx, exec, draft);
|
|
1067
1203
|
const warnings = [recoveredNotice(k, draft)].filter(Boolean); // 新草稿提示由 tool() 包装统一注入(S2,只出一份)
|
|
1068
1204
|
const fields = Array.isArray(args.fields) ? args.fields.map(String).filter(Boolean) : [];
|
|
1069
1205
|
let outDraft = draft;
|
|
@@ -1126,7 +1262,7 @@ function apply(ctx){
|
|
|
1126
1262
|
if(args.creator_notes!==undefined) assignIfChanged(cur.data, "creator_notes", String(args.creator_notes).slice(0,3000), changed);
|
|
1127
1263
|
if(args.creator!==undefined) assignIfChanged(cur.data, "creator", String(args.creator).slice(0,120), changed);
|
|
1128
1264
|
setDraft(ctx, cur, exec);
|
|
1129
|
-
const wf =
|
|
1265
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1130
1266
|
const warnings = [];
|
|
1131
1267
|
if(recNote) warnings.push(recNote);
|
|
1132
1268
|
// P1-4:patch 类默认不再回吐整份 draft —— 长卡下每改一个字段都要模型重读全卡,纯烧上下文。
|
|
@@ -1221,7 +1357,7 @@ function apply(ctx){
|
|
|
1221
1357
|
if(w.daily) push("daily", "@@depth 5\n"+w.daily, 7);
|
|
1222
1358
|
}
|
|
1223
1359
|
setDraft(ctx, cur, exec);
|
|
1224
|
-
const wf =
|
|
1360
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1225
1361
|
const autoEntries = cur.data.character_book.entries.filter(isAutoEntry);
|
|
1226
1362
|
const warnings = [];
|
|
1227
1363
|
if(recNote) warnings.push(recNote);
|
|
@@ -1312,7 +1448,7 @@ function apply(ctx){
|
|
|
1312
1448
|
});
|
|
1313
1449
|
});
|
|
1314
1450
|
setDraft(ctx, cur, exec);
|
|
1315
|
-
const wf =
|
|
1451
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1316
1452
|
const warnings = [];
|
|
1317
1453
|
if(recNote) warnings.push(recNote);
|
|
1318
1454
|
const res = { ok:true, added: args.entries.length, loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount,
|
|
@@ -1349,7 +1485,7 @@ function apply(ctx){
|
|
|
1349
1485
|
// 重排 insertion_order
|
|
1350
1486
|
cur.data.character_book.entries.forEach((e,i)=> e.insertion_order=i);
|
|
1351
1487
|
setDraft(ctx, cur, exec);
|
|
1352
|
-
const wf =
|
|
1488
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1353
1489
|
const deleted = beforeCount - wf.loreCount;
|
|
1354
1490
|
const warnings = [];
|
|
1355
1491
|
if(beforeRec) warnings.push(beforeRec);
|
|
@@ -1396,7 +1532,7 @@ function apply(ctx){
|
|
|
1396
1532
|
if(args.use_regex!==undefined) assignIfChanged(e, "use_regex", !!args.use_regex, changed);
|
|
1397
1533
|
if(typeof args.priority==="number") assignIfChanged(e, "priority", args.priority, changed);
|
|
1398
1534
|
setDraft(ctx, cur, exec);
|
|
1399
|
-
const wf =
|
|
1535
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1400
1536
|
const warnings = [];
|
|
1401
1537
|
if(recNote) warnings.push(recNote);
|
|
1402
1538
|
const res = { ok:true, entry:e, changed, loreCount: wf.loreCount,
|
|
@@ -1432,7 +1568,7 @@ function apply(ctx){
|
|
|
1432
1568
|
if(Array.isArray(args.group_only_greetings)) cur.data.group_only_greetings = args.group_only_greetings.map(String).slice(0,10);
|
|
1433
1569
|
if(args.mes_example!==undefined) cur.data.mes_example = String(args.mes_example).slice(0,5000);
|
|
1434
1570
|
setDraft(ctx, cur, exec);
|
|
1435
|
-
const wf =
|
|
1571
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1436
1572
|
// 若世界书/五维未齐,警告但仍写入,让 validate 卡住
|
|
1437
1573
|
const warnings = [];
|
|
1438
1574
|
if(recNote) warnings.push(recNote);
|
|
@@ -1495,7 +1631,7 @@ function apply(ctx){
|
|
|
1495
1631
|
setDraft(ctx,cur,exec);
|
|
1496
1632
|
const k = keyFor(ctx, exec);
|
|
1497
1633
|
writeOriginSync(k, obj.id, obj.meta.name);
|
|
1498
|
-
const wf =
|
|
1634
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1499
1635
|
const res = { ok:true, id:obj.id, meta:obj.meta, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1500
1636
|
loreCount: wf.loreCount,
|
|
1501
1637
|
message: "已载入「" + obj.meta.name + "」(id " + obj.id + ",世界书 " + wf.loreCount + " 条)。之后保存(不传 id)会更新这一张。" };
|
|
@@ -1561,7 +1697,7 @@ function apply(ctx){
|
|
|
1561
1697
|
const k = keyFor(ctx, exec);
|
|
1562
1698
|
if(args.confirm !== true){
|
|
1563
1699
|
const cur = getDraft(ctx, exec);
|
|
1564
|
-
const wf =
|
|
1700
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1565
1701
|
return { ok:false, needsConfirm:true, workflow: wf,
|
|
1566
1702
|
message:"重置会清空当前草稿的全部内容(角色/五维/世界书/问候语)且无法撤销。当前草稿:" + (String(cur.data.name||"").trim() || "(未命名)") + ",世界书 " + wf.loreCount + " 条。请先向用户说明并征求同意;同意后再以 confirm:true 重新调用。" };
|
|
1567
1703
|
}
|
|
@@ -1574,7 +1710,7 @@ function apply(ctx){
|
|
|
1574
1710
|
// S2(0.7.0):成因是「有意重置」,不是「会话里突然没有草稿了」—— 清掉待告知标记,
|
|
1575
1711
|
// 之后写入不再报「此前有未保存的内容可能已丢失」(第四轮实测:刚重置完就被吓一句,纯误报)。
|
|
1576
1712
|
draftMeta(k).pendingFresh = null;
|
|
1577
|
-
const wf =
|
|
1713
|
+
const wf = await workflowFor(ctx, exec, fresh);
|
|
1578
1714
|
return { ok:true, reset:true, workflow: wf,
|
|
1579
1715
|
message:"已重置为空白草稿(并解除与已存卡的来源关联,之后保存会新建一张)。" + wf.next };
|
|
1580
1716
|
}
|
|
@@ -1609,7 +1745,7 @@ function apply(ctx){
|
|
|
1609
1745
|
if(typeof args.token_budget==="number") assignIfChanged(book, "token_budget", Math.max(0, Math.round(args.token_budget)), changed);
|
|
1610
1746
|
if(args.recursive_scanning!==undefined) assignIfChanged(book, "recursive_scanning", !!args.recursive_scanning, changed);
|
|
1611
1747
|
setDraft(ctx, cur, exec);
|
|
1612
|
-
const wf =
|
|
1748
|
+
const wf = await workflowFor(ctx, exec, cur);
|
|
1613
1749
|
const used = lorebookTokens(book.entries);
|
|
1614
1750
|
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 },
|
|
1615
1751
|
lorebookTokens: used, draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
@@ -1636,12 +1772,17 @@ function apply(ctx){
|
|
|
1636
1772
|
const book = cur.data.character_book || {};
|
|
1637
1773
|
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1638
1774
|
const texts = [];
|
|
1775
|
+
// 0.9.0 报告:共享书里的条目在真实聊天里**同样会被触发**(客户端把全局书与卡内 character_book
|
|
1776
|
+
// 叠加),所以模拟必须把它们一起算 —— 否则会把「会命中」报成「不会命中」(实测低估约 3 倍)。
|
|
1777
|
+
const scope = await sharedBookScopeFor(ctx, exec);
|
|
1778
|
+
const shared = scope.entries;
|
|
1779
|
+
const pool = entries.map(e => ({ e: e, src: "card" })).concat(shared.map(e => ({ e: e, src: "shared" })));
|
|
1639
1780
|
if(typeof args.text === "string" && args.text.trim()) texts.push(args.text);
|
|
1640
1781
|
if(Array.isArray(args.texts)) args.texts.forEach(x => { if(typeof x === "string" && x.trim()) texts.push(x); });
|
|
1641
1782
|
if(!texts.length) return { ok:false, error:"请用 text 或 texts 提供至少一句测试文本" };
|
|
1642
1783
|
const hitsOf = (line) => {
|
|
1643
1784
|
const lower = line.toLowerCase();
|
|
1644
|
-
return
|
|
1785
|
+
return pool.filter(({ e }) => {
|
|
1645
1786
|
if(!e || e.enabled === false) return false;
|
|
1646
1787
|
if(e.constant) return true; // 常驻条目永远命中
|
|
1647
1788
|
const keys = Array.isArray(e.keys) ? e.keys : [];
|
|
@@ -1655,19 +1796,30 @@ function apply(ctx){
|
|
|
1655
1796
|
};
|
|
1656
1797
|
const results = texts.map(line => {
|
|
1657
1798
|
const hits = hitsOf(line);
|
|
1799
|
+
const cardHits = hits.filter(h => h.src === "card").map(h => h.e);
|
|
1800
|
+
const sharedHits = hits.filter(h => h.src === "shared").map(h => h.e);
|
|
1658
1801
|
return {
|
|
1659
|
-
text: line, hitCount: hits.length, tokens: lorebookTokens(hits),
|
|
1660
|
-
|
|
1661
|
-
|
|
1802
|
+
text: line, hitCount: hits.length, tokens: lorebookTokens(hits.map(h => h.e)),
|
|
1803
|
+
cardTokens: lorebookTokens(cardHits), sharedTokens: lorebookTokens(sharedHits),
|
|
1804
|
+
hits: hits.map(h => ({ name: String(h.e.name||""), source: h.src, constant: !!h.e.constant,
|
|
1805
|
+
keys: Array.isArray(h.e.keys)?h.e.keys:[], priority: h.e.priority })),
|
|
1806
|
+
missed: pool.filter(p => p.e.enabled !== false && hits.indexOf(p) < 0)
|
|
1807
|
+
.map(p => String(p.e.name||"") + (p.src === "shared" ? "(共享书)" : "")),
|
|
1662
1808
|
};
|
|
1663
1809
|
});
|
|
1664
1810
|
const warnings = [];
|
|
1665
|
-
if(
|
|
1811
|
+
if(pool.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
|
|
1666
1812
|
const never = entries.filter(e => e.enabled !== false && !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
|
|
1667
1813
|
if(never.length) warnings.push(never.length + " 条非 constant 条目没有 keys,永远触发不了:" + never.map(e=>String(e.name||"")).join("、"));
|
|
1814
|
+
const totalTokens = lorebookTokens(entries.concat(shared));
|
|
1668
1815
|
const res = { ok:true, simulated: results.length, results,
|
|
1669
|
-
lorebookTokens:
|
|
1670
|
-
|
|
1816
|
+
lorebookTokens: totalTokens,
|
|
1817
|
+
tokenSplit: { card: lorebookTokens(entries), shared: lorebookTokens(shared), total: totalTokens },
|
|
1818
|
+
sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: scope.tokens } : null,
|
|
1819
|
+
scanDepth: book.scan_depth,
|
|
1820
|
+
message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token"
|
|
1821
|
+
+ (r.sharedTokens ? ",其中共享书 " + r.sharedTokens : "") + ")").join(";")
|
|
1822
|
+
+ (shared.length ? "。已把该组的共享世界书(" + shared.length + " 条 / 约 " + scope.tokens + " token)一并算入。" : "") };
|
|
1671
1823
|
if(warnings.length) res.warnings = warnings;
|
|
1672
1824
|
return res;
|
|
1673
1825
|
}
|
|
@@ -1684,39 +1836,57 @@ function apply(ctx){
|
|
|
1684
1836
|
const book = cur.data.character_book || {};
|
|
1685
1837
|
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
1686
1838
|
const issues = [];
|
|
1687
|
-
const add = (level, message, names) => issues.push({ level, message, entries: names || [] });
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
const
|
|
1694
|
-
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1839
|
+
const add = (level, code, message, names, source) => issues.push({ level, code, message, entries: names || [], source: source || "card" });
|
|
1840
|
+
// 0.9.0 报告:共享世界书是同一个世界观的一部分,聊天时与卡内 character_book **一起进 prompt**,
|
|
1841
|
+
// 所以预算、最坏情况都得把它们并起来算;同时拆出「卡内 / 共享 / 合计」三段,免得又变成一笔糊涂账。
|
|
1842
|
+
const scope = await sharedBookScopeFor(ctx, exec);
|
|
1843
|
+
const shared = scope.entries;
|
|
1844
|
+
const m = lorebookMetrics(entries, shared); // 三段基础统计(一处实现,与 cc_validate 同一份)
|
|
1845
|
+
const all = m.all;
|
|
1846
|
+
const cardTokens = m.card.tokens;
|
|
1847
|
+
const sharedTokens = m.shared.tokens;
|
|
1848
|
+
const used = m.total.tokens;
|
|
1849
|
+
// —— 逐条质量检查:卡内与共享书**都要查**,触发词冲突跨两者一起算 ——
|
|
1850
|
+
// 第五轮报告修正了上一版写下的「边界」:那时说「共享书条目的质量走 cc_get_shared_book 那条线」,
|
|
1851
|
+
// 可那条线上根本没有这个能力(只有重复检测),共享书于是成了没人体检的一格 —— 往书里塞一条同时
|
|
1852
|
+
// 踩三类的坏条目,两个工具都不出声。共享书多卡共用、又没有「卡」这个载体,**比卡内更需要主动报**。
|
|
1853
|
+
// 现在两边共用 auditLoreEntries 这一把尺子。
|
|
1854
|
+
auditLoreEntries([{ source: "card", entries: entries }, { source: "shared", entries: shared }])
|
|
1855
|
+
.issues.forEach(i => issues.push(i));
|
|
1856
|
+
// 存在性检查走**合计**口径(卡内 + 共享书)—— 第四轮报告:这里此前只数卡内,于是同一份返回里
|
|
1857
|
+
// 「constantEntries: 1」与「没有 constant 常驻条目」并列出现(计算已经改对了,判断没跟着换变量)。
|
|
1858
|
+
if(all.length && !m.total.constant) add("warn", "no-constant", "没有 constant 常驻条目(卡内与共享书都没有):世界观基调只在命中触发词时才出现", [], "mixed");
|
|
1859
|
+
if(all.length && !m.total.hasDecorator) add("warn", "no-decorator", "所有条目都没有 @@decorator(@@position/@@depth/@@activate),插入位置全用默认值", [], "mixed");
|
|
1702
1860
|
const budget = Number(book.token_budget||0) || 0;
|
|
1703
|
-
if(budget>0 && used>budget)
|
|
1861
|
+
if(budget>0 && used>budget){
|
|
1862
|
+
add("warn", "over-budget", "估算 " + used + " token(卡内 " + cardTokens + " + 共享书 " + sharedTokens + "),已超出本卡 token_budget=" + budget
|
|
1863
|
+
+ (shared.length ? " —— 注意共享书是**独立一本**、由客户端分别管预算,调本卡预算盖不住它" : ""), [], "card");
|
|
1864
|
+
}
|
|
1704
1865
|
// N2(0.7.0):总量与「单次触发」是两个独立风险,只报总量会让「调大预算」看起来像修好了。
|
|
1705
|
-
const worst = worstCaseTrigger(
|
|
1706
|
-
const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries,
|
|
1866
|
+
const worst = worstCaseTrigger(all);
|
|
1867
|
+
const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries,
|
|
1868
|
+
overBudget: budget > 0 && worst.tokens > budget, sharedEntries: shared.length };
|
|
1707
1869
|
if(worstCase.overBudget){
|
|
1708
|
-
add("warn", "最坏情况单次触发约 " + worst.tokens + " token(含常驻 " + worst.constantTokens + ",预算 " + budget + "):「" + worst.entries.join(" + ") + "」一句话就吃光整份预算(调大 token_budget 盖不住这条)。", worst.entries);
|
|
1870
|
+
add("warn", "worst-case-over-budget", "最坏情况单次触发约 " + worst.tokens + " token(含常驻 " + worst.constantTokens + ",预算 " + budget + "):「" + worst.entries.join(" + ") + "」一句话就吃光整份预算(调大 token_budget 盖不住这条)。", worst.entries, "mixed");
|
|
1709
1871
|
}
|
|
1710
|
-
if(!String(book.name||"").trim()) add("warn", "世界书没有名字(character_book.name 为空,导出后是一本无名的书)");
|
|
1872
|
+
if(!String(book.name||"").trim()) add("warn", "book-unnamed", "世界书没有名字(character_book.name 为空,导出后是一本无名的书)", [], "card");
|
|
1873
|
+
if(shared.length && !String(scope.bookName||"").trim()) add("warn", "shared-book-unnamed", "该组的共享世界书没有名字(导出成 lorebook_v3 文件后别人不知道叫什么)—— 用 cc_patch_shared_book 的 name 参数命名", [], "shared");
|
|
1711
1874
|
const errorCount = issues.filter(i => i.level === "error").length;
|
|
1875
|
+
const lints = issues.filter(i => i.level === "error" || i.level === "warn").length;
|
|
1712
1876
|
return { ok:true,
|
|
1713
|
-
entries:
|
|
1714
|
-
|
|
1715
|
-
|
|
1877
|
+
entries: all.length,
|
|
1878
|
+
entriesSplit: { card: entries.length, shared: shared.length, total: all.length },
|
|
1879
|
+
manualEntries: m.card.manual,
|
|
1880
|
+
constantEntries: m.total.constant,
|
|
1881
|
+
lorebookTokens: used,
|
|
1882
|
+
tokenSplit: { card: cardTokens, shared: sharedTokens, total: used },
|
|
1883
|
+
sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: scope.tokens } : null,
|
|
1884
|
+
tokenBudget: budget, worstCase,
|
|
1716
1885
|
issues, errorCount, warningCount: issues.length - errorCount,
|
|
1717
|
-
message: issues.length
|
|
1886
|
+
message: (issues.length
|
|
1718
1887
|
? "体检发现 " + issues.length + " 个问题(" + errorCount + " 个会真的不工作):" + issues.map(i=>i.message).join(";")
|
|
1719
|
-
: "世界书体检通过:没有发现结构性问题。"
|
|
1888
|
+
: "世界书体检通过:没有发现结构性问题。")
|
|
1889
|
+
+ (shared.length ? "(含该组共享世界书 " + shared.length + " 条 / 约 " + sharedTokens + " token,共 " + lints + " 项在册)" : "") };
|
|
1720
1890
|
}
|
|
1721
1891
|
});
|
|
1722
1892
|
|
|
@@ -1797,7 +1967,7 @@ function apply(ctx){
|
|
|
1797
1967
|
// ⚠️ 分组 rev 与草稿 rev 是两件事:expectedGroupRev 校验**图谱**,expectedRev(包装层)校验**草稿**。
|
|
1798
1968
|
// 曾经两者同名,结果传分组 rev 会被包装层当成草稿 rev 拒掉(且错误文案说「草稿已被外部修改」,极具误导性)。
|
|
1799
1969
|
if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
|
|
1800
|
-
return { ok:false,
|
|
1970
|
+
return { ok:false, staleGroupRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
|
|
1801
1971
|
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1802
1972
|
error: "分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重新读一遍再改。" };
|
|
1803
1973
|
}
|
|
@@ -1841,7 +2011,7 @@ function apply(ctx){
|
|
|
1841
2011
|
return { ok:false, error:"分组「" + g.name + "」里没有这张卡的节点:先在工坊关系视图里把它加入分组(或用 cc_patch_relations 加一个 kind:\"card\" 的节点)。" };
|
|
1842
2012
|
}
|
|
1843
2013
|
if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
|
|
1844
|
-
return { ok:false,
|
|
2014
|
+
return { ok:false, staleGroupRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
|
|
1845
2015
|
error:"分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重读再渲染。" };
|
|
1846
2016
|
}
|
|
1847
2017
|
const section = renderRelationsSection(r.myNode.id, g.nodes, g.edges, { includeIncoming: args.includeIncoming === true });
|
|
@@ -1864,7 +2034,7 @@ function apply(ctx){
|
|
|
1864
2034
|
setDraft(ctx, cur, exec);
|
|
1865
2035
|
}
|
|
1866
2036
|
return { ok:true, changed, changedFields: changed ? ["description"] : [], outgoing, description: after,
|
|
1867
|
-
group: { id: g.id, name: g.name, rev: g.rev }, workflow:
|
|
2037
|
+
group: { id: g.id, name: g.name, rev: g.rev }, workflow: await workflowFor(ctx, exec, cur),
|
|
1868
2038
|
message: (changed ? "已把 " + outgoing + " 条关系写进 description 的【关系】段" : "description 里已经是最新关系,无需改动")
|
|
1869
2039
|
+ "。注意:这只改了**当前草稿**,要落库请再调 cc_save_to_library(或让用户在工坊点「保存当前」)。" };
|
|
1870
2040
|
}
|
|
@@ -1888,7 +2058,10 @@ function apply(ctx){
|
|
|
1888
2058
|
const g = r.group;
|
|
1889
2059
|
const memberIds = await groupMemberIds(g.id);
|
|
1890
2060
|
const cards = await libraryCardsFor(memberIds);
|
|
1891
|
-
|
|
2061
|
+
// 0.9.0:体检的是**这个组**,所以读这个组的共享书(显式传 groupId 时它与当前卡所在组可能不同)
|
|
2062
|
+
const bkFound = await readBookRaw(g.id);
|
|
2063
|
+
const sharedEntries = bkFound ? normalizeBook(bkFound.obj, g.id).data.entries.filter(e => e && e.enabled !== false) : [];
|
|
2064
|
+
const res = checkGroup(g, memberIds, cards, sharedEntries);
|
|
1892
2065
|
const errorCount = res.issues.filter(i => i.level === "error").length;
|
|
1893
2066
|
return { ok:true,
|
|
1894
2067
|
group: { id: g.id, name: g.name, rev: g.rev },
|
|
@@ -2037,9 +2210,16 @@ function apply(ctx){
|
|
|
2037
2210
|
const g = normalizeGroup(found.obj);
|
|
2038
2211
|
const members = await groupMemberIds(gid);
|
|
2039
2212
|
if(args.confirm !== true){
|
|
2213
|
+
// 盘点必须把**共享世界书**也算进「会被删掉且不可恢复」的东西里(第二轮报告 4.1):
|
|
2214
|
+
// 那可能是用户花了很多时间整理的设定,而原先的破坏性提示只提了节点/关系。
|
|
2215
|
+
const bf = await readBookRaw(gid);
|
|
2216
|
+
const bookCount = bf ? normalizeBook(bf.obj, gid).data.entries.length : 0;
|
|
2040
2217
|
return { ok:false, needConfirm:true,
|
|
2041
2218
|
group: { id:g.id, name:g.name, nodeCount:g.nodes.length, edgeCount:g.edges.length, memberCount:members.length },
|
|
2042
|
-
|
|
2219
|
+
bookEntries: bookCount, hasBook: !!bf,
|
|
2220
|
+
message:"「" + g.name + "」里有 " + g.nodes.length + " 个节点 / " + g.edges.length + " 条关系,删组会一起删掉且不可恢复;" + members.length + " 张成员卡不会被删,只会离开这个组。"
|
|
2221
|
+
+ (bf ? "本组另有共享世界书 " + bookCount + " 条(" + (String(normalizeBook(bf.obj, gid).data.name||"").trim() || "未命名") + "),也会一并删除且不可恢复。" : "")
|
|
2222
|
+
+ "与用户确认后重调并带 confirm:true。" };
|
|
2043
2223
|
}
|
|
2044
2224
|
const del = await deleteGroupWithMembers(gid);
|
|
2045
2225
|
const bookDeleted = await deleteBook(gid); // 0.9.0:组级共享世界书随组一起删(留下就是孤儿文件)
|
|
@@ -2105,11 +2285,13 @@ function apply(ctx){
|
|
|
2105
2285
|
|
|
2106
2286
|
// ———————————————— 组级共享世界书(0.9.0)————————————————
|
|
2107
2287
|
// 一组一份:世界设定(年表/势力/地理/力量/日常)提到共享书,成员卡里只留角色私有条目。
|
|
2108
|
-
//
|
|
2288
|
+
// 导出后有两种形态,各有各的读者(0.9.1 补):CCv3 官方 lorebook_v3 JSON 给认这个规范的客户端;
|
|
2289
|
+
// **SillyTavern 只认它自己的 `{entries:{…}}`**(ST 的 /api/worldinfo/import 首句就查顶层 entries,
|
|
2290
|
+
// 拖 lorebook_v3 进去实测 400)—— 工坊导出页两枚按钮分别对应,别混着说。
|
|
2109
2291
|
|
|
2110
2292
|
tool({
|
|
2111
2293
|
name: "cc_get_shared_book",
|
|
2112
|
-
description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」——
|
|
2294
|
+
description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」—— 那些就是该提升到共享书的。共享书是独立文件,工坊导出页给两种形态:**CCv3 独立世界书**(`{spec:\"lorebook_v3\", data}`,给支持该规范的客户端绑成全局世界书)与 **SillyTavern 世界书**(`{entries:{…}}`)。⚠️ SillyTavern 的导入接口只认后者(它连 `lorebook_v3` 都不认,直接拖前者进去会被判 400 Bad Request)—— 所以「交给客户端」时要说清是哪种,别让用户拿到 CCv3 那份去导 ST。",
|
|
2113
2295
|
parameters: {
|
|
2114
2296
|
type:"object",
|
|
2115
2297
|
properties:{
|
|
@@ -2126,26 +2308,44 @@ function apply(ctx){
|
|
|
2126
2308
|
const g = r.group;
|
|
2127
2309
|
const found = await readBookRaw(g.id);
|
|
2128
2310
|
const cards = await bookCardsFor(await groupMemberIds(g.id));
|
|
2129
|
-
const
|
|
2311
|
+
const book = found ? normalizeBook(found.obj, g.id) : null;
|
|
2312
|
+
// 把**书**一起交给 scan —— 否则「卡内条目与共享书逐字重复」这一类(`cc_apply_shared_book` 回填进卡的副本)
|
|
2313
|
+
// 谁都看不见:卡片之间没有重复、书里也没有重复,于是两个工具都说「没有重复」,而那几份副本就躺在卡里
|
|
2314
|
+
// 等着和共享书双重插入(第二轮报告 P1)。
|
|
2315
|
+
const scan = scanSharedEntries(cards, book ? book.data.entries : []);
|
|
2130
2316
|
const duplicates = scan.candidates.map(c => ({ signature: c.signature, name: c.name, cardNames: c.cardNames,
|
|
2131
2317
|
chars: c.chars, savedChars: c.savedChars, nameMismatch: c.nameMismatch, keysMismatch: c.keysMismatch, preview: c.preview }));
|
|
2318
|
+
const bookDuplicates = scan.bookDupes.map(c => ({ kind: c.kind, signature: c.signature, name: c.name, cardNames: c.cardNames,
|
|
2319
|
+
chars: c.chars, savedChars: c.savedChars, preview: c.preview }));
|
|
2320
|
+
// 共享书**自己的**逐条质量检查(第五轮报告:这条线上此前只有重复检测,那四类检查在书上是空的)。
|
|
2321
|
+
// 与 cc_lint_lorebook 共用同一把尺子(auditLoreEntries)—— 一份 issue 带 contentKeys,可直接改。
|
|
2322
|
+
const bookLint = auditLoreEntries([{ source: "shared", entries: book ? book.data.entries : [] }]);
|
|
2132
2323
|
const base = { ok:true, group:{ id:g.id, name:g.name, rev:g.rev }, exists: !!found,
|
|
2133
|
-
bookRev:
|
|
2324
|
+
bookRev: book ? bookRevOf(book) : 0,
|
|
2134
2325
|
cards: cards.map(c => ({ id:c.id, name:c.name, entries:(c.entries||[]).length })),
|
|
2135
|
-
duplicates, duplicateStats: scan.stats
|
|
2326
|
+
duplicates, bookDuplicates, duplicateStats: scan.stats,
|
|
2327
|
+
lint: { issues: bookLint.issues, errorCount: bookLint.errorCount, warningCount: bookLint.issues.length - bookLint.errorCount } };
|
|
2136
2328
|
if(!found){
|
|
2137
2329
|
return Object.assign(base, { message: "分组「" + g.name + "」还没有共享世界书。"
|
|
2138
2330
|
+ (scan.candidates.length
|
|
2139
2331
|
? "成员卡之间有 " + scan.candidates.length + " 条内容重复的条目(合计可省约 " + scan.stats.savedChars + " 字符)—— 用 cc_promote_shared_entries 提升到共享书,再从各卡里删掉。"
|
|
2140
2332
|
: "成员卡之间也没有内容重复的条目。") });
|
|
2141
2333
|
}
|
|
2142
|
-
const book = normalizeBook(found.obj, g.id);
|
|
2143
2334
|
const tokens = estimateTokens(book.data.entries.map(e => e.content).join("\n"));
|
|
2144
2335
|
return Object.assign(base, {
|
|
2145
2336
|
book: bookBrief(book, args.includeContent === true), tokens,
|
|
2337
|
+
lastPromote: (book.data.extensions && book.data.extensions.cc && book.data.extensions.cc.lastPromote) || null,
|
|
2146
2338
|
message: "共享世界书「" + book.data.name + "」共 " + book.data.entries.length + " 条(rev " + bookRevOf(book) + ",约 " + tokens + " token"
|
|
2147
2339
|
+ ",预算 " + book.data.token_budget + ");分组里有 " + cards.length + " 张成员卡"
|
|
2148
|
-
+ (scan.candidates.length ? "
|
|
2340
|
+
+ (scan.candidates.length ? ",卡片之间有 " + scan.candidates.length + " 条内容重复(可用 cc_promote_shared_entries 收进共享书)" : ",卡片之间已无重复条目")
|
|
2341
|
+
+ (scan.bookDupes.length
|
|
2342
|
+
? ";另有 " + scan.bookDupes.length + " 条在卡里与共享书**逐字重复**(共 " + scan.stats.bookDuplicatedEntries + " 份副本、约 " + scan.stats.bookSavedChars + " 字符)—— 共享书里已经有了,用 cc_promote_shared_entries 清掉卡里那几份即可(不会再往书里加)"
|
|
2343
|
+
: "")
|
|
2344
|
+
+ (bookLint.issues.length
|
|
2345
|
+
? ";⚠️ **这本书自身有 " + bookLint.issues.length + " 个条目质量问题**(" + bookLint.issues.map(i => i.message).join(";")
|
|
2346
|
+
+ ")—— 共享书在**每一张**成员卡上都生效:一条死 key 等于在所有卡上都没写、一条维度名 key 等于在每张卡上白吃 token。按 lint.issues[].contentKeys 用 cc_patch_shared_book 直接改"
|
|
2347
|
+
: "")
|
|
2348
|
+
+ "。" });
|
|
2149
2349
|
}
|
|
2150
2350
|
});
|
|
2151
2351
|
|
|
@@ -2173,7 +2373,7 @@ function apply(ctx){
|
|
|
2173
2373
|
message:"当前卡还没入库或没加入分组,共享世界书无处可放 —— 先把它归到一组。" };
|
|
2174
2374
|
const res = await patchSharedBook(r.group.id, args);
|
|
2175
2375
|
if(!res.ok){
|
|
2176
|
-
return { ok:false,
|
|
2376
|
+
return { ok:false, staleBookRev: res.staleBookRev === true,
|
|
2177
2377
|
expectedBookRev: res.expectedBookRev == null ? null : res.expectedBookRev,
|
|
2178
2378
|
actualBookRev: res.actualBookRev == null ? null : res.actualBookRev,
|
|
2179
2379
|
error: String(res.error || "") };
|
|
@@ -2209,19 +2409,35 @@ function apply(ctx){
|
|
|
2209
2409
|
const res = await promoteSharedBook(r.group.id, args);
|
|
2210
2410
|
if(!res.ok) return { ok:false, error: String(res.error || "") };
|
|
2211
2411
|
if(res.dryRun){
|
|
2212
|
-
|
|
2213
|
-
|
|
2412
|
+
const nothing = !res.wouldPromote && !res.wouldClean;
|
|
2413
|
+
return { ok:true, dryRun:true, wouldPromote: res.wouldPromote, wouldClean: res.wouldClean, unknownKeys: res.unknownKeys,
|
|
2414
|
+
removedTotal: res.removedTotal, savedChars: res.savedChars, perCard: res.perCard, duplicates: res.candidates,
|
|
2415
|
+
bookDuplicates: res.bookDuplicates, stats: res.stats,
|
|
2214
2416
|
group:{ id:r.group.id, name:r.group.name }, bookRev: res.rev,
|
|
2215
|
-
message:
|
|
2216
|
-
? "
|
|
2217
|
-
: "
|
|
2417
|
+
message: nothing
|
|
2418
|
+
? "没有可提升的重复条目" + (res.unknownKeys.length ? "(" + res.unknownKeys.length + " 个 signature 不认识,已忽略)" : "") + "。"
|
|
2419
|
+
: "预览:"
|
|
2420
|
+
+ (res.wouldPromote ? "把 " + res.wouldPromote + " 条提升进共享世界书," : "")
|
|
2421
|
+
+ (res.wouldClean ? "清掉卡里 " + res.wouldClean + " 条与共享书逐字重复的副本(书里已经有了,不会再往书里加)," : "")
|
|
2422
|
+
+ "将从成员卡里共删掉 " + res.removedTotal + " 条(可省约 " + res.savedChars + " 字符)。确认后带 apply:true 重调。" };
|
|
2218
2423
|
}
|
|
2219
|
-
|
|
2424
|
+
// 库里已经删干净了,但**草稿是另一个副本** —— 顺手同步(见 syncDraftAfterPromote 的注释),
|
|
2425
|
+
// 同步不了就退回警告,绝不静默地让重复在下次保存时复活。
|
|
2426
|
+
const draftSync = await syncDraftAfterPromote(ctx, exec, r.group.id,
|
|
2427
|
+
(res.perCard || []).filter(c => c && !c.error && c.removed > 0).map(c => c.id));
|
|
2428
|
+
return {
|
|
2429
|
+
ok:true, dryRun:false, promoted: res.promoted, cleaned: res.cleaned, removedTotal: res.removedTotal, unknownKeys: res.unknownKeys,
|
|
2220
2430
|
bookRev: res.bookRev, bookEntries: res.bookEntries, perCard: res.perCard, stats: res.stats,
|
|
2221
|
-
|
|
2431
|
+
bookDuplicates: res.bookDuplicates,
|
|
2432
|
+
group:{ id:r.group.id, name:r.group.name }, draftSync,
|
|
2222
2433
|
message: "已把 " + res.promoted + " 条设定提升进共享世界书(rev " + res.bookRev + ",共 " + res.bookEntries + " 条),并从成员卡里删掉 " + res.removedTotal + " 条重复:"
|
|
2223
2434
|
+ res.perCard.map(c => c.name + " -" + (c.removed || 0)).join("、")
|
|
2224
|
-
+ "
|
|
2435
|
+
+ (res.cleaned ? "(其中 " + res.cleaned + " 条是共享书里早就有的副本,只做了清理)" : "")
|
|
2436
|
+
+ (draftSync.synced ? "。你正在编辑的草稿就是其中一张,已同步删掉 " + draftSync.removed + " 条 —— 接着保存不会把重复写回去。" : "")
|
|
2437
|
+
+ (draftSync.remaining ? "。⚠️ 当前草稿里还有 " + draftSync.remaining + " 条与共享书重复(" + draftSync.names.slice(0,5).join("、") + "):这次只改了库里的卡,草稿里那份还在 —— 保存前先 cc_load_from_library 重新载入,否则重复会被原样写回卡里。" : "")
|
|
2438
|
+
+ "导出时记得一并给客户端配这本共享世界书(工坊导出页可导出独立 JSON)。",
|
|
2439
|
+
warnings: draftSync.remaining ? ["这次提升只改了**库里的卡**,你正在编辑的那份草稿没被改动,里面仍有 " + draftSync.remaining + " 条与共享书逐字重复(" + draftSync.names.slice(0,5).join("、") + ")。接着 cc_save_to_library 会把它们原样写回卡里、重复照旧 —— 保存前先 cc_load_from_library 重新载入这张卡(或手动删掉这几条)。"] : undefined,
|
|
2440
|
+
};
|
|
2225
2441
|
}
|
|
2226
2442
|
});
|
|
2227
2443
|
|
|
@@ -2276,12 +2492,27 @@ function apply(ctx){
|
|
|
2276
2492
|
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; }); });
|
|
2277
2493
|
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"];
|
|
2278
2494
|
const bytes = Buffer.byteLength(json, "utf8");
|
|
2495
|
+
// 0.9.0 报告:导出预览只说「卡内世界书」,用户会以为这就是全部 —— 而客户端实际加载时
|
|
2496
|
+
// 是「卡内 character_book + 该组共享书」叠加,所以这里必须把共享书单列一行。
|
|
2497
|
+
const scope = await sharedBookScopeFor(ctx, exec);
|
|
2498
|
+
const shared = scope.entries;
|
|
2499
|
+
const cardTokens = lorebookTokens(entries);
|
|
2500
|
+
const sharedTokens = lorebookTokens(shared);
|
|
2279
2501
|
return { ok:true,
|
|
2280
|
-
bytes, tokens: estimateTokens(json),
|
|
2502
|
+
bytes, tokens: estimateTokens(json),
|
|
2503
|
+
lorebookTokens: cardTokens + sharedTokens,
|
|
2504
|
+
lorebookSplit: { card: cardTokens, shared: sharedTokens, total: cardTokens + sharedTokens },
|
|
2505
|
+
sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: sharedTokens,
|
|
2506
|
+
hint: "独立一本,工坊导出页可单独导出(CCv3 客户端拿「CCv3 独立世界书」,**SillyTavern 拿「SillyTavern 世界书」**);若客户端不支持全局世界书,可在导出时并入本卡。导卡时(JSON / PNG / CHARX)会**自动在导出副本上**写 `data.extensions.world = <书名>`(ST 的角色↔世界书绑定),PNG 里同时写 `ccv3`(纯 CCv3)与 `chara`(V2 回填)两个块 —— 草稿与库里的卡一个字节都不动" } : null,
|
|
2281
2507
|
exportedFields, extensionsExported: Object.keys(d.extensions || {}),
|
|
2282
2508
|
worldDuplicatedChars: dupChars,
|
|
2283
2509
|
loreCount: entries.length, manualLoreCount: entries.filter(e => !isAutoEntry(e)).length,
|
|
2284
|
-
message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token
|
|
2510
|
+
message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中本卡世界书 " + cardTokens + " token"
|
|
2511
|
+
+ (shared.length
|
|
2512
|
+
? ";**本卡所属组另有共享世界书「" + (String(scope.bookName||"").trim() || "未命名") + "」" + shared.length + " 条 / 约 " + sharedTokens
|
|
2513
|
+
+ " token** —— 客户端实际加载时两者叠加(合计 " + (cardTokens + sharedTokens) + " token),可单独导出该文件(**SillyTavern 要用「SillyTavern 世界书」那份**)或导出时并入本卡"
|
|
2514
|
+
: "")
|
|
2515
|
+
+ ";五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
|
|
2285
2516
|
}
|
|
2286
2517
|
});
|
|
2287
2518
|
|
|
@@ -2294,7 +2525,7 @@ function apply(ctx){
|
|
|
2294
2525
|
const d = getDraft(ctx, exec);
|
|
2295
2526
|
const k = keyFor(ctx, exec);
|
|
2296
2527
|
const st = draftStatusFor(k, d);
|
|
2297
|
-
const wf =
|
|
2528
|
+
const wf = await workflowFor(ctx, exec, d);
|
|
2298
2529
|
const book = d.data.character_book || {};
|
|
2299
2530
|
const entries = Array.isArray(book.entries) ? book.entries : [];
|
|
2300
2531
|
const errors=[], warnings=[];
|
|
@@ -2305,9 +2536,13 @@ function apply(ctx){
|
|
|
2305
2536
|
if(!Array.isArray(d.data.group_only_greetings) || d.data.group_only_greetings.length<1) errors.push("缺 group_only_greetings(至少 1 条必填)");
|
|
2306
2537
|
if(wf.state.world !== "ok") errors.push("五维世界观未齐:需 timeline/factions/geo/power/daily 至少 3 项(当前 "+wf.worldFilled+"/5),请调 cc_patch_world");
|
|
2307
2538
|
if(wf.state.lore !== "ok"){
|
|
2308
|
-
errors.push(wf.
|
|
2309
|
-
? "
|
|
2310
|
-
: "
|
|
2539
|
+
errors.push(wf.manualLoreTotal === 0
|
|
2540
|
+
? "世界书只有自动条目(合计 "+wf.loreTotal+" 条"+(wf.sharedLoreCount ? ",其中共享书 "+wf.sharedLoreCount+" 条" : "")+",全部由 autoLorebook 生成),未完成共创:请调 cc_add_lorebook_entries 至少补 1 条(合计≥5 条)"
|
|
2541
|
+
: "世界书未齐:卡内 "+wf.loreCount+" 条 + 共享书 "+wf.sharedLoreCount+" 条 = 合计 "+wf.loreTotal+" 条,需至少 5 条,请调 cc_add_lorebook_entries 补到 5 条");
|
|
2542
|
+
} else if(wf.loreCount < 5){
|
|
2543
|
+
// 达标靠的是组里那本共享书(第二轮报告 P0 的正解):**不阻塞**,但必须把「导出时要带上它」说清楚 ——
|
|
2544
|
+
// 否则用户把这张「卡内只有 3 条」的卡发出去,对方客户端没有共享书就等于世界书缺失。
|
|
2545
|
+
warnings.push("本卡内只有 "+wf.loreCount+" 条世界书,达标靠的是本组共享世界书「"+(wf.sharedBook && String(wf.sharedBook.name||"").trim() ? wf.sharedBook.name : "未命名")+"」的 "+wf.sharedLoreCount+" 条(合计 "+wf.loreTotal+" 条)。**共享书不会跟着卡走**:导出时请把它一并交给客户端(工坊导出页有两枚按钮 —— 支持 `lorebook_v3` 的客户端拿「CCv3 独立世界书」,**SillyTavern 必须拿「SillyTavern 世界书」**,ST 的导入接口只认 `{entries:{…}}` 形态);对方客户端不支持全局世界书时,在导出页勾选「并入本卡」。导卡时若该组有共享书,会**自动在导出副本上**写上 ST 的世界书绑定(`data.extensions.world = 书名`),PNG 里同时带 `ccv3` 与 `chara` 两个块。");
|
|
2311
2546
|
}
|
|
2312
2547
|
// —— 质量(P1-5:此前 valid=true 只代表「四件套非空」,给用户「卡没问题了」的错觉)——
|
|
2313
2548
|
if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充(缺失时模型只能靠 description 猜场景)");
|
|
@@ -2317,8 +2552,9 @@ function apply(ctx){
|
|
|
2317
2552
|
if(!String(d.data.creator||"").trim()) warnings.push("data.creator 为空(导出后别人看不到作者)—— 用 cc_patch_character 的 creator 参数写入,或让用户在工坊「角色基础」里手动填。");
|
|
2318
2553
|
if(!String(book.name||"").trim()) warnings.push("世界书没有名字(character_book.name 为空),导出后是一本无名的书;可用 cc_patch_book_meta 命名");
|
|
2319
2554
|
if(!String(book.description||"").trim()) warnings.push("character_book.description 为空,建议一句话说明这本世界书的作用");
|
|
2320
|
-
|
|
2321
|
-
if(
|
|
2555
|
+
// 存在性检查按**合计**口径(卡内 + 共享书)—— 见 workflowStatus 里 constantEntries 的注释
|
|
2556
|
+
if(wf.loreTotal > 0 && wf.constantEntries.total === 0) warnings.push("世界书(卡内 + 共享书)没有任何 constant 常驻条目(否则全靠触发词命中)");
|
|
2557
|
+
if(wf.loreTotal > 0 && !entries.some(e => String((e && e.content) || "").includes("@@")) && wf.sharedHasDecorator !== true) warnings.push("世界书条目建议带 @@decorator(@@position/@@depth/@@activate)");
|
|
2322
2558
|
const deadKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length===0));
|
|
2323
2559
|
if(deadKeys.length) warnings.push(deadKeys.length + " 条非 constant 条目没有 keys(永远触发不了):" + deadKeys.map(e=>String(e.name||"未命名")).join("、"));
|
|
2324
2560
|
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))));
|
|
@@ -2340,17 +2576,27 @@ function apply(ctx){
|
|
|
2340
2576
|
done: valid,
|
|
2341
2577
|
readyToValidate: !valid && wf.readyToValidate,
|
|
2342
2578
|
next,
|
|
2343
|
-
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid),
|
|
2579
|
+
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid, wf.sharedLoreCount),
|
|
2344
2580
|
});
|
|
2345
2581
|
const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
|
|
2582
|
+
const sharedUsed = (wf.sharedBook && Number(wf.sharedBook.tokens)) || 0;
|
|
2346
2583
|
return { ok:true, valid, done: valid, errors, warnings: warningsAll,
|
|
2347
2584
|
loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
|
|
2348
|
-
|
|
2585
|
+
// 世界书的两个口径都报出来(第二轮报告 P0):卡内 / 共享 / 合计 —— `cc_lint_lorebook` 早就是这个口径了,
|
|
2586
|
+
// 「收尾判定」再只数卡内,等于让按最佳实践去重过的卡永远不合格。
|
|
2587
|
+
sharedLoreCount: wf.sharedLoreCount, loreTotal: wf.loreTotal,
|
|
2588
|
+
constantEntries: wf.constantEntries,
|
|
2589
|
+
lorebookTokens: used + sharedUsed,
|
|
2590
|
+
lorebookSplit: { card: used, shared: sharedUsed, total: used + sharedUsed },
|
|
2591
|
+
sharedBook: wf.sharedBook,
|
|
2592
|
+
tokenBudget: budget,
|
|
2349
2593
|
draftStatus: st, workflow: wfDone,
|
|
2350
2594
|
// R3(0.6.0):message 不再拼接 next —— 那会让「校验通过 ✓」连说两遍(两个来源拼接未去重)。
|
|
2351
2595
|
// 下一步只保留 workflow.next 一处。
|
|
2352
2596
|
message: valid
|
|
2353
|
-
? "校验通过 ✓ 工作流完成(世界书 " + wf.
|
|
2597
|
+
? "校验通过 ✓ 工作流完成(世界书 " + wf.loreTotal + " 条"
|
|
2598
|
+
+ (wf.sharedLoreCount ? " = 卡内 " + wf.loreCount + " + 共享书 " + wf.sharedLoreCount : "")
|
|
2599
|
+
+ " / 估算 " + (used + sharedUsed) + " token)。"
|
|
2354
2600
|
: "校验未通过:" + errors.length + " 项待补。" };
|
|
2355
2601
|
}
|
|
2356
2602
|
});
|
|
@@ -2461,7 +2707,24 @@ async function bookCardsFor(ids){
|
|
|
2461
2707
|
}
|
|
2462
2708
|
return out;
|
|
2463
2709
|
}
|
|
2464
|
-
|
|
2710
|
+
/**
|
|
2711
|
+
* 新建共享书的默认名:`<组名>·共享设定`(拿不到组名才退回「共享设定」)。
|
|
2712
|
+
* 第二轮报告 4.2:同一个人的多个组各建一本、全叫「共享设定」,导出成文件后根本分不清是哪一组。
|
|
2713
|
+
* 两半同构(`tests/shared-book.test.mjs` 直接比对),改这里记得同步 `lib/index.js`。
|
|
2714
|
+
*/
|
|
2715
|
+
function defaultBookName(groupName){
|
|
2716
|
+
const n = String(groupName == null ? "" : groupName).trim();
|
|
2717
|
+
return n ? n + "·共享设定" : "共享设定";
|
|
2718
|
+
}
|
|
2719
|
+
/** 新建一本空共享书(名字优先用调用方给的,否则 `<组名>·共享设定`)。两半同构。 */
|
|
2720
|
+
async function newBookFor(gid, explicitName){
|
|
2721
|
+
const want = String(explicitName == null ? "" : explicitName).trim();
|
|
2722
|
+
if(want) return normalizeBook({ data: { name: want } }, gid);
|
|
2723
|
+
let gname = "";
|
|
2724
|
+
try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
|
|
2725
|
+
return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
|
|
2726
|
+
}
|
|
2727
|
+
function scanSharedEntries(cards, bookEntries){
|
|
2465
2728
|
const list = Array.isArray(cards) ? cards : [];
|
|
2466
2729
|
const seen = new Map();
|
|
2467
2730
|
list.forEach(c => {
|
|
@@ -2478,10 +2741,26 @@ function scanSharedEntries(cards){
|
|
|
2478
2741
|
const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
|
|
2479
2742
|
.sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
|
|
2480
2743
|
.map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
|
|
2744
|
+
// 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
|
|
2745
|
+
// 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
|
|
2746
|
+
// 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
|
|
2747
|
+
const bookDupes = [];
|
|
2748
|
+
(Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
|
|
2749
|
+
const content = String((be && be.content) == null ? "" : be.content);
|
|
2750
|
+
const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
|
|
2751
|
+
if(!g || g.content !== content) return;
|
|
2752
|
+
bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
|
|
2753
|
+
content, keys: (be.keys || []).map(String), chars: content.length,
|
|
2754
|
+
cards: g.cards.slice(), cardNames: g.cardNames.slice(),
|
|
2755
|
+
preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
|
|
2756
|
+
});
|
|
2481
2757
|
const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
|
|
2482
|
-
return { candidates, stats: { cards: list.length, totalEntries,
|
|
2758
|
+
return { candidates, bookDupes, stats: { cards: list.length, totalEntries,
|
|
2483
2759
|
duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
|
|
2484
|
-
savedChars: candidates.reduce((n, g) => n + g.savedChars, 0)
|
|
2760
|
+
savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
|
|
2761
|
+
bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
|
|
2762
|
+
bookDupeCount: bookDupes.length,
|
|
2763
|
+
bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0) } };
|
|
2485
2764
|
}
|
|
2486
2765
|
function mergeBookEntries(bookEntries, cardEntries){
|
|
2487
2766
|
const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
|
|
@@ -2501,19 +2780,22 @@ function applyBookPromotion(cards, bookEntries, signatures){
|
|
|
2501
2780
|
const promoted = [];
|
|
2502
2781
|
const nextCards = (Array.isArray(cards) ? cards : []).map(c => {
|
|
2503
2782
|
const kept = [];
|
|
2504
|
-
let removed = 0;
|
|
2783
|
+
let removed = 0, removedChars = 0;
|
|
2505
2784
|
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2506
2785
|
const sig = contentKeyOf(e);
|
|
2507
2786
|
if(want.has(sig)){
|
|
2508
2787
|
if(!have.has(sig)){ have.add(sig); book.push(makeBookEntry(e)); promoted.push(sig); }
|
|
2509
2788
|
removed += 1;
|
|
2789
|
+
removedChars += String(e.content == null ? "" : e.content).length;
|
|
2510
2790
|
return;
|
|
2511
2791
|
}
|
|
2512
2792
|
kept.push(makeBookEntry(e));
|
|
2513
2793
|
});
|
|
2514
|
-
return { id: c.id, name: c.name, entries: kept, removed };
|
|
2794
|
+
return { id: c.id, name: c.name, entries: kept, removed, removedChars };
|
|
2515
2795
|
});
|
|
2516
|
-
return { bookEntries: book, cards: nextCards, promoted,
|
|
2796
|
+
return { bookEntries: book, cards: nextCards, promoted,
|
|
2797
|
+
removedTotal: nextCards.reduce((n, c) => n + c.removed, 0),
|
|
2798
|
+
savedChars: nextCards.reduce((n, c) => n + c.removedChars, 0) };
|
|
2517
2799
|
}
|
|
2518
2800
|
|
|
2519
2801
|
// —— 0.9.0:三个编排动作(与宿主半 cc_patchBook / cc_promoteBook / cc_applyBook 端点行为等价)——
|
|
@@ -2537,9 +2819,9 @@ async function patchSharedBook(groupId, args){
|
|
|
2537
2819
|
const gid = safeGroupId(groupId);
|
|
2538
2820
|
if(!gid) return { ok: false, error: "需要 groupId" };
|
|
2539
2821
|
const found = await readBookRaw(gid);
|
|
2540
|
-
const cur = found ? normalizeBook(found.obj, gid) :
|
|
2822
|
+
const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid, args.name);
|
|
2541
2823
|
if(typeof args.expectedBookRev === "number" && args.expectedBookRev !== bookRevOf(cur)){
|
|
2542
|
-
return { ok: false,
|
|
2824
|
+
return { ok: false, staleBookRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur),
|
|
2543
2825
|
error: "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先 cc_get_shared_book 重新读一遍再改。" };
|
|
2544
2826
|
}
|
|
2545
2827
|
const applied = [], rejected = [];
|
|
@@ -2586,21 +2868,44 @@ async function promoteSharedBook(groupId, args){
|
|
|
2586
2868
|
const gid = safeGroupId(groupId);
|
|
2587
2869
|
if(!gid) return { ok: false, error: "需要 groupId" };
|
|
2588
2870
|
const found = await readBookRaw(gid);
|
|
2589
|
-
|
|
2871
|
+
let cur;
|
|
2872
|
+
if(found){ cur = normalizeBook(found.obj, gid); }
|
|
2873
|
+
else cur = await newBookFor(gid); // 默认名 `<组名>·共享设定`(第二轮报告 4.2)
|
|
2590
2874
|
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2875
|
+
// 候选有两类:① 卡与卡之间重复(提升进书);② 卡内条目与**共享书**逐字重复(书里已经有,只需清理卡内副本)。
|
|
2876
|
+
// ② 是 `cc_apply_shared_book` 回填卡之后的必然产物:卡里那份与共享书会**双重插入**,而此前两个工具都看不见它
|
|
2877
|
+
// (卡片之间没重复、书里也没重复)—— 第二轮报告 P1。
|
|
2878
|
+
const scan = scanSharedEntries(cards, cur.data.entries);
|
|
2879
|
+
const known = new Map(scan.candidates.concat(scan.bookDupes).map(c => [c.signature, c]));
|
|
2880
|
+
const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String)
|
|
2881
|
+
: scan.candidates.map(c => c.signature).concat(scan.bookDupes.map(c => c.signature));
|
|
2594
2882
|
const unknownKeys = requested.filter(s => !known.has(s));
|
|
2595
2883
|
const use = requested.filter(s => known.has(s));
|
|
2884
|
+
const bookSigs = new Set(cur.data.entries.map(contentKeyOf));
|
|
2885
|
+
const promoteSigs = use.filter(s => !bookSigs.has(s)); // 真·提升(书里还没有)
|
|
2886
|
+
const cleanSigs = use.filter(s => bookSigs.has(s)); // 书里已有 → 只删卡里那份,不再往书里加
|
|
2596
2887
|
const plan = applyBookPromotion(cards, cur.data.entries, use);
|
|
2597
|
-
const summary = (g) => ({ signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames,
|
|
2888
|
+
const summary = (g) => ({ kind: g.kind || "card-vs-card", signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames,
|
|
2889
|
+
chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch === true, keysMismatch: g.keysMismatch === true, preview: g.preview });
|
|
2598
2890
|
if(args.apply !== true){
|
|
2599
|
-
return { ok: true, dryRun: true, changed: false, wouldPromote:
|
|
2891
|
+
return { ok: true, dryRun: true, changed: false, wouldPromote: promoteSigs.length, wouldClean: cleanSigs.length, unknownKeys, removedTotal: plan.removedTotal, savedChars: plan.savedChars,
|
|
2600
2892
|
perCard: plan.cards.map(c => ({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length })),
|
|
2601
|
-
candidates: scan.candidates.map(summary), stats: scan.stats, rev: bookRevOf(cur) };
|
|
2893
|
+
candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats, rev: bookRevOf(cur) };
|
|
2602
2894
|
}
|
|
2603
|
-
|
|
2895
|
+
// 这次删掉的条目里,有多少是「提升」(卡内副本搬进书)之外的**纯清理**(书里早就有)—— 报告 P1 要的可对账字段。
|
|
2896
|
+
const promotedCopies = cards.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.filter(e => promoteSigs.indexOf(contentKeyOf(e)) >= 0).length : 0), 0);
|
|
2897
|
+
const cleaned = Math.max(0, plan.removedTotal - promotedCopies);
|
|
2898
|
+
// 记下「这次提升省了多少」——报告 4.3:去重完再查时 savedChars 已经是 0,收益就查不到了;
|
|
2899
|
+
// 存进 extensions.cc.lastPromote,让 cc_get_shared_book 能一直回显。
|
|
2900
|
+
const ccExt = Object.assign({}, (cur.data.extensions && cur.data.extensions.cc) || {}, {
|
|
2901
|
+
lastPromote: { savedChars: plan.savedChars, removed: plan.removedTotal, promoted: plan.promoted.length, cleaned, at: nowSec() },
|
|
2902
|
+
});
|
|
2903
|
+
const saved = await writeBook(gid, Object.assign({}, cur, {
|
|
2904
|
+
data: Object.assign({}, cur.data, {
|
|
2905
|
+
entries: plan.bookEntries,
|
|
2906
|
+
extensions: Object.assign({}, cur.data.extensions, { cc: ccExt }),
|
|
2907
|
+
}),
|
|
2908
|
+
}));
|
|
2604
2909
|
const touched = [];
|
|
2605
2910
|
for(const c of plan.cards){
|
|
2606
2911
|
if(!c.removed) continue;
|
|
@@ -2613,9 +2918,77 @@ async function promoteSharedBook(groupId, args){
|
|
|
2613
2918
|
touched.push({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length });
|
|
2614
2919
|
}catch(e){ touched.push({ id: c.id, name: c.name, error: String(e && e.message || e) }); }
|
|
2615
2920
|
}
|
|
2616
|
-
return { ok: true, dryRun: false, changed: true, promoted: plan.promoted.length, removedTotal: plan.removedTotal, unknownKeys,
|
|
2617
|
-
bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched,
|
|
2921
|
+
return { ok: true, dryRun: false, changed: true, promoted: plan.promoted.length, cleaned, removedTotal: plan.removedTotal, savedChars: plan.savedChars, unknownKeys,
|
|
2922
|
+
bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched,
|
|
2923
|
+
candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats };
|
|
2924
|
+
}
|
|
2925
|
+
// —— 提升之后的「草稿同步」(用户报告的第 1 条)——
|
|
2926
|
+
// 为什么必须有这一步:`cc_promote_shared_entries` 改的是**库里的卡**,而模型正在编辑的是**草稿**
|
|
2927
|
+
// ——两个副本。库里那张卡的重复条目被删掉了,草稿里却还原样留着,模型接着调 cc_save_to_library
|
|
2928
|
+
// 就会把重复**原样写回卡里**:刚做完的去重等于白做,而且全程零提示(测试里能直接看到
|
|
2929
|
+
// 「simulate 同时命中 年表@card 与 年表@shared」就是这个状态)。
|
|
2930
|
+
//
|
|
2931
|
+
// 判据与库里那一次**完全相同**:内容已经进了共享书、且**逐字相符**。内容签名只用来筛候选,
|
|
2932
|
+
// 绝不按哈希删数据(哈希碰撞不能变成删用户内容)——内容被改过的那几条一律不动,只报告。
|
|
2933
|
+
/** 草稿里「与共享书逐字重复」的条目(`dupes`)与其余条目(`kept`)。纯函数,便于直接测。 */
|
|
2934
|
+
function draftBookDupes(draftEntries, bookEntries){
|
|
2935
|
+
const book = new Map();
|
|
2936
|
+
(Array.isArray(bookEntries) ? bookEntries : []).forEach(e => {
|
|
2937
|
+
const content = String((e && e.content) == null ? "" : e.content);
|
|
2938
|
+
if(!content.trim()) return;
|
|
2939
|
+
const sig = contentKeyOf(e);
|
|
2940
|
+
if(!book.has(sig)) book.set(sig, content);
|
|
2941
|
+
});
|
|
2942
|
+
const dupes = [], kept = [];
|
|
2943
|
+
(Array.isArray(draftEntries) ? draftEntries : []).forEach(e => {
|
|
2944
|
+
const content = String((e && e.content) == null ? "" : e.content);
|
|
2945
|
+
if(content.trim() && book.get(contentKeyOf(e)) === content) dupes.push(e);
|
|
2946
|
+
else kept.push(e);
|
|
2947
|
+
});
|
|
2948
|
+
return { dupes, kept };
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* 提升执行后同步草稿:若**当前草稿的来源卡正是这次被删过条目的卡之一**,就把草稿里那几条一起删掉。
|
|
2952
|
+
*
|
|
2953
|
+
* 两条出口:
|
|
2954
|
+
* - `synced:true` → 已同步(`removed` 条),模型可以放心 `cc_save_to_library`;
|
|
2955
|
+
* - `remaining>0` → 草稿里仍有 N 条与共享书重复(来源卡不适用,或内容被改过逐字比对不中)
|
|
2956
|
+
* —— **不动手**,只把条数报回去,由调用方给出「保存前先 cc_load_from_library 重新载入」的警告。
|
|
2957
|
+
*
|
|
2958
|
+
* 并发安全:`getDraft()` 会以盘上 rev 为准自动重载,而「读草稿 → 过滤 → `setDraft`」之间
|
|
2959
|
+
* **没有任何 await**,所以别人的写入不会被半路覆盖(要么它在我读之前完成、要么在我写之后)。
|
|
2960
|
+
*/
|
|
2961
|
+
async function syncDraftAfterPromote(ctx, exec, groupId, promotedCardIds){
|
|
2962
|
+
const ids = (Array.isArray(promotedCardIds) ? promotedCardIds : []).map(String).filter(Boolean);
|
|
2963
|
+
const gid = safeGroupId(groupId) || String(groupId || "");
|
|
2964
|
+
const k = keyFor(ctx, exec);
|
|
2965
|
+
const origin = readOriginSync(k);
|
|
2966
|
+
const myCardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
2967
|
+
const applicable = !!myCardId && ids.indexOf(myCardId) >= 0;
|
|
2968
|
+
const found = gid ? await readBookRaw(gid) : null;
|
|
2969
|
+
if(!found) return { applicable, synced:false, removed:0, remaining:0, names:[], cardId: myCardId || null };
|
|
2970
|
+
const book = normalizeBook(found.obj, gid);
|
|
2971
|
+
let draft;
|
|
2972
|
+
try{ draft = getDraft(ctx, exec); }
|
|
2973
|
+
catch(e){ return { applicable, synced:false, removed:0, remaining:0, names:[], cardId: myCardId || null, error: String((e && e.message) || e) }; }
|
|
2974
|
+
const cb = (draft && draft.data && draft.data.character_book) || {};
|
|
2975
|
+
const entries = Array.isArray(cb.entries) ? cb.entries : [];
|
|
2976
|
+
const overlap = draftBookDupes(entries, book.data.entries);
|
|
2977
|
+
const names = overlap.dupes.map(e => String((e && e.name) || "").trim() || "(未命名)");
|
|
2978
|
+
if(!overlap.dupes.length) return { applicable, synced:false, removed:0, remaining:0, names:[], cardId: myCardId || null };
|
|
2979
|
+
if(!applicable) return { applicable:false, synced:false, removed:0, remaining: overlap.dupes.length, names, cardId: myCardId || null };
|
|
2980
|
+
try{
|
|
2981
|
+
const data = draft.data;
|
|
2982
|
+
const next = Object.assign({}, draft, { data: Object.assign({}, data, {
|
|
2983
|
+
character_book: Object.assign({}, cb, { entries: overlap.kept }),
|
|
2984
|
+
}) });
|
|
2985
|
+
setDraft(ctx, next, exec); // 走同一套落盘 + rev 递增(N4:内容没变则一次都不写)
|
|
2986
|
+
return { applicable:true, synced:true, removed: overlap.dupes.length, remaining:0, names:[], cardId: myCardId };
|
|
2987
|
+
}catch(e){
|
|
2988
|
+
return { applicable:true, synced:false, removed:0, remaining: overlap.dupes.length, names, cardId: myCardId, error: String((e && e.message) || e) };
|
|
2989
|
+
}
|
|
2618
2990
|
}
|
|
2991
|
+
|
|
2619
2992
|
/** 回填:把共享书条目合并进某张卡(同内容已存在则跳过)—— 给不支持全局世界书的客户端兜底。 */
|
|
2620
2993
|
async function applySharedBook(groupId, cardId, dryRun){
|
|
2621
2994
|
const gid = safeGroupId(groupId);
|
|
@@ -2637,6 +3010,38 @@ async function applySharedBook(groupId, cardId, dryRun){
|
|
|
2637
3010
|
return { ok: true, cardId, added: merged.added.length, changed: true, loreCount: merged.entries.length };
|
|
2638
3011
|
}
|
|
2639
3012
|
|
|
3013
|
+
/**
|
|
3014
|
+
* 度量类工具的统一口径:世界设定现在有一部分活在**组级共享书**里,所以任何「这张卡有多少条 /
|
|
3015
|
+
* 多少 token」的统计都必须把它算上,并拆成 **卡内 / 共享 / 合计** 三段。
|
|
3016
|
+
*
|
|
3017
|
+
* 为什么单列一个函数:LLM 侧实测报告指出 —— 共享书改变了**数据的位置**,却没有改变工具的**度量口径**;
|
|
3018
|
+
* 四个「读」类工具(simulate / lint / group_check / preview_export)全都默认「卡内 character_book
|
|
3019
|
+
* 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),lint 的 worstCase
|
|
3020
|
+
* 漏掉共享书里的 constant 条目,group_check 的群聊固定开销也只算了一半。
|
|
3021
|
+
*
|
|
3022
|
+
* 没有来源卡 / 没有分组 / 该组还没有书 → 一律返回空(调用方无需自己判空)。
|
|
3023
|
+
*/
|
|
3024
|
+
async function sharedBookScopeFor(ctx, exec){
|
|
3025
|
+
const k = keyFor(ctx, exec);
|
|
3026
|
+
const origin = readOriginSync(k);
|
|
3027
|
+
const cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
3028
|
+
const empty = { cardId, groupId: "", bookName: "", rev: 0, entries: [], tokens: 0, constantTokens: 0 };
|
|
3029
|
+
if(!cardId) return empty;
|
|
3030
|
+
const mine = (await listLibraryEntries()).find(e => e.id === cardId);
|
|
3031
|
+
const gid = (mine && mine.groupId) ? String(mine.groupId) : "";
|
|
3032
|
+
if(!gid) return empty;
|
|
3033
|
+
const found = await readBookRaw(gid);
|
|
3034
|
+
if(!found) return Object.assign({}, empty, { groupId: gid });
|
|
3035
|
+
const book = normalizeBook(found.obj, gid);
|
|
3036
|
+
const entries = book.data.entries.filter(e => e && e.enabled !== false);
|
|
3037
|
+
return {
|
|
3038
|
+
cardId, groupId: gid, bookName: book.data.name, rev: bookRevOf(book), entries,
|
|
3039
|
+
tokens: lorebookTokens(entries),
|
|
3040
|
+
constantTokens: entries.filter(e => e.constant).reduce((n, e) => n + estimateTokens(e && e.content), 0),
|
|
3041
|
+
lastPromote: (book.data.extensions && book.data.extensions.cc && book.data.extensions.cc.lastPromote) || null,
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
|
|
2640
3045
|
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
2641
3046
|
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|
|
2642
3047
|
groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
@@ -2648,4 +3053,6 @@ export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, orig
|
|
|
2648
3053
|
BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
|
|
2649
3054
|
contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
|
|
2650
3055
|
bookEntryBrief, bookBrief, patchSharedBook, promoteSharedBook, applySharedBook,
|
|
3056
|
+
// 提升后的草稿同步(用户报告的第 1、2 条):纯函数 + 同步动作,测试直接比对行为
|
|
3057
|
+
draftBookDupes, syncDraftAfterPromote,
|
|
2651
3058
|
REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
|