@xia-sc/dsh-cc-studio 0.9.0 → 0.10.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/lib/index.js CHANGED
@@ -9,6 +9,11 @@ import { fileURLToPath } from "node:url";
9
9
  import { join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { inflateSync as zlibInflateSync } from "node:zlib";
12
+ // 卡组层(0.10.0):卡组作用域组装 / 世界书分段统计 / 条目体检的**唯一实现**,与预设半共用同一个文件。
13
+ // 以前这些逻辑两半各抄一份、靠测试逐字比对维持同步 —— 「改了四个、漏了第五个」就是这么来的。
14
+ import { REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END,
15
+ estimateTokens, contentKeyOf, scanSharedEntries,
16
+ assembleScope, groupMetrics, runGroupChecks } from "./group-layer.js";
12
17
 
13
18
  const name = "dsh-cc-studio";
14
19
  // dsh >= 0.1.5-rc.1: this package owns its HTTP route outright.
@@ -397,10 +402,7 @@ function isCcPreset(ctx, sessionId){
397
402
  // 渲染:关系只活在插件侧,要让分享出去的卡自包含,就把它渲染进 description 的固定标记区
398
403
  // (<!-- cc-relations:begin … --> … <!-- cc-relations:end -->)—— 那是**显式动作**,永不自动同步。
399
404
  // ——————————————————————————————————————————————————————————————
400
- const REL_TYPES = ["family","romance","mentor","friend","colleague","rival","enemy","debt","superior","other"];
401
- const REL_TYPE_LABELS = { family:"亲属", romance:"恋慕", mentor:"师徒", friend:"挚友", colleague:"同门", rival:"竞争", enemy:"敌对", debt:"恩义", superior:"上下级", other:"其他" };
402
- const REL_BEGIN = "<!-- cc-relations:begin";
403
- const REL_END = "<!-- cc-relations:end -->";
405
+ // 关系图谱常量(0.10.0 起由 lib/group-layer.js 提供,两半共用一份)—— 这里不再重复定义。
404
406
 
405
407
  function groupDirs(){ return dataRoots("cc-groups"); }
406
408
  function groupDir(){ return groupDirs()[0]; }
@@ -412,13 +414,8 @@ function groupPath(id){ return join(groupDir(), safeGroupId(id) + ".json"); }
412
414
  function newGroupId(){ return Date.now() + "-" + Math.random().toString(36).slice(2, 6); }
413
415
  async function ensureGroupDir(){ try{ await mkdir(groupDir(), { recursive: true }); }catch{} }
414
416
 
415
- /** 粗略 token 估算(与 lib/agent.js 同构):CJK 一字≈1 token,其余 4 字符≈1 token。 */
416
- function estimateTokensLite(text){
417
- const s = String(text || "");
418
- let cjk = 0;
419
- for(const ch of s){ if(ch.charCodeAt(0) > 0x2E7F) cjk++; }
420
- return Math.ceil(cjk + (s.length - cjk) / 4);
421
- }
417
+ /** 粗略 token 估算:委托 lib/group-layer.js 的 estimateTokens(两半共用一份,不再各写一遍)。 */
418
+ function estimateTokensLite(text){ return estimateTokens(text); }
422
419
  function relTypeOf(t){ const s = String(t == null ? "" : t); return REL_TYPES.indexOf(s) >= 0 ? s : "other"; }
423
420
  function relLabel(edge){ return (edge && edge.label && String(edge.label).trim()) ? String(edge.label).trim() : (REL_TYPE_LABELS[(edge && edge.type) || "other"] || "其他"); }
424
421
 
@@ -621,55 +618,16 @@ function spliceRelations(description, section, meta){
621
618
  /**
622
619
  * 跨卡体检(纯函数)。members / cards 由调用方给:
623
620
  * members = 组内卡的 id 列表;cards = [{id, name, description, constantTokens, tokenBudget}]
621
+ *
622
+ * 0.10.0:实现搬进 lib/group-layer.js 的 `runGroupChecks()`(检查注册表,与预设半共用同一个文件)。
623
+ * 这里保留旧签名与返回形状(issues[].level/code/message/refs + stats),所以既有调用点与测试都不用改;
624
+ * 新检查项只往那里的 GROUP_CHECKS 加一项。opts.checks 可只跑指定类别。
624
625
  */
625
- function checkGroup(group, members, cards, sharedEntries){
626
- const g = normalizeGroup(group);
627
- const issues = [];
628
- const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
629
- const nodeIds = new Set(g.nodes.map(n => n.id));
630
- const byId = new Map((Array.isArray(cards) ? cards : []).map(c => [c.id, c]));
631
- const dangling = g.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
632
- if(dangling.length) add("error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
633
- const orphans = g.nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
634
- if(orphans.length) add("warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
635
- const seen = new Map();
636
- g.nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
637
- const k = String(x || "").toLowerCase();
638
- if(!k) return;
639
- if(!seen.has(k)) seen.set(k, []);
640
- if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
641
- }));
642
- const dup = [...seen.entries()].filter(p => p[1].length > 1);
643
- if(dup.length) add("error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
644
- const unknown = g.edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
645
- if(unknown.length) add("warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
646
- const memberCards = (Array.isArray(members) ? members : []).map(m => byId.get(m)).filter(Boolean);
647
- const unrendered = [], stale = [];
648
- memberCards.forEach(c => {
649
- const desc = String(c.description || "");
650
- const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
651
- if(i < 0 || j < 0){ unrendered.push(c.id); return; }
652
- const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
653
- if(!m || Number(m[1]) !== g.rev) stale.push(c.id);
654
- });
655
- if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
656
- if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
657
- // 群聊版的「单次触发」陷阱:每张卡的 constant 每轮都进上下文,卡越多固定开销越大
658
- const cardConst = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
659
- // 0.9.0 报告:共享世界书的 constant 条目在群聊里**同样每轮插入**(独立一本、由客户端叠加),
660
- // 所以「群聊每轮固定开销」必须是 卡内 + 共享 的合计 —— 只报卡内会低报一半。与工具半同构。
661
- const sharedConst = (Array.isArray(sharedEntries) ? sharedEntries : [])
662
- .filter(e => e && e.enabled !== false && e.constant)
663
- .reduce((n, e) => n + estimateTokensLite(e && e.content), 0);
664
- const constTokens = cardConst + sharedConst;
665
- const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
666
- if((memberCards.length > 1 || sharedConst > 0) && constTokens > minBudget * 0.8){
667
- add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻 " + cardConst
668
- + (sharedConst ? " + 共享书常驻 " + sharedConst : "") + " = 合计约 " + constTokens
669
- + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
670
- }
671
- return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
672
- constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget } };
626
+ function checkGroup(group, members, cards, sharedEntries, opts){
627
+ const o = opts || {};
628
+ return runGroupChecks({ group: normalizeGroup(group), members: members, cards: cards,
629
+ sharedEntries: sharedEntries, metrics: o.metrics || null, scope: o.scope || null,
630
+ opts: { checks: o.checks } });
673
631
  }
674
632
 
675
633
  /** 读分组:主根优先,回退旧根(与角色库同规)。 */
@@ -743,11 +701,65 @@ async function readLibraryRaw(id){
743
701
  throw lastErr || new Error("找不到角色卡: " + String(id));
744
702
  }
745
703
  // ========== PNG / CHARX 共享 ==========
704
+ /** tags 在 V1/ST 那边可能是逗号分隔的字符串;V2/V3 要数组。 */
705
+ function normTags(v){
706
+ if(Array.isArray(v)) return v.map(function(x){ return String(x); });
707
+ if(typeof v==="string") return v.split(",").map(function(s){ return s.trim(); }).filter(Boolean);
708
+ return [];
709
+ }
710
+ /**
711
+ * V2(乃至 V1)卡 → CCv3 升级(0.9.1 的 B2)。返回 `{card, from}`,`from` 为 null = 本来就是 V3。
712
+ *
713
+ * 为什么做:SillyTavern 里**绝大多数存量卡是 V2**(ST 界面新建的就是 V2 形状,ST 只是把 `spec` 改写成
714
+ * 了 v3 标签),而工坊此前只收 `chara_card_v3` —— 用户把 ST 里那张卡的 PNG/JSON 拖进来,只会得到
715
+ * 一句「spec 需为 chara_card_v3」。升级规则照 ST 自己的 `readFromV2()`(`characters.js:504-556`):
716
+ * 顶层那些 V1 平铺字段并进 `data`(**只在 `data` 里没有该值时**,不覆盖 V2 自己的值),其余不认识
717
+ * 的字段原样留着。`from` 交给调用方,用来**明确告诉用户「这份是升上来的」**(不静默变换)。
718
+ */
719
+ function upgradeCardToV3(obj){
720
+ if(!obj || typeof obj!=="object") throw new Error("卡必须是对象");
721
+ if(obj.spec==="chara_card_v3" && obj.data && typeof obj.data==="object") return { card: obj, from: null, copied: [] };
722
+ const out=JSON.parse(JSON.stringify(obj));
723
+ const copied=[];
724
+ const hasData=!!(out.data && typeof out.data==="object");
725
+ if(!hasData){
726
+ // 纯 V1:整份包成 data
727
+ const flat={};
728
+ ["name","description","personality","scenario","first_mes","mes_example"].forEach(function(k){ if(out[k]!==undefined) flat[k]=out[k]; });
729
+ flat.creator_notes = out.creator_notes!==undefined? out.creator_notes : (out.creatorcomment!==undefined? out.creatorcomment : "");
730
+ flat.system_prompt="";
731
+ flat.post_history_instructions="";
732
+ flat.alternate_greetings=Array.isArray(out.alternate_greetings)? out.alternate_greetings : [];
733
+ flat.tags=normTags(out.tags);
734
+ flat.creator=out.creator!==undefined? out.creator : "";
735
+ flat.character_version=out.character_version!==undefined? out.character_version : "";
736
+ flat.extensions={};
737
+ if(out.talkativeness!==undefined) flat.extensions.talkativeness=out.talkativeness;
738
+ if(out.fav!==undefined) flat.extensions.fav=out.fav===true;
739
+ out.data=flat;
740
+ } else {
741
+ out.data.extensions=(out.data.extensions && typeof out.data.extensions==="object")? out.data.extensions : {};
742
+ ["name","description","personality","scenario","first_mes","mes_example"].forEach(function(k){
743
+ if(out.data[k]===undefined && out[k]!==undefined){ out.data[k]=out[k]; copied.push("data."+k); }
744
+ });
745
+ if(out.data.tags===undefined && out.tags!==undefined){ out.data.tags=normTags(out.tags); copied.push("data.tags"); }
746
+ if(out.data.creator_notes===undefined){
747
+ if(out.creator_notes!==undefined){ out.data.creator_notes=out.creator_notes; copied.push("data.creator_notes"); }
748
+ else if(out.creatorcomment!==undefined){ out.data.creator_notes=out.creatorcomment; copied.push("data.creator_notes"); }
749
+ }
750
+ if(out.talkativeness!==undefined && out.data.extensions.talkativeness===undefined){ out.data.extensions.talkativeness=out.talkativeness; copied.push("data.extensions.talkativeness"); }
751
+ if(out.fav!==undefined && out.data.extensions.fav===undefined){ out.data.extensions.fav=out.fav===true; copied.push("data.extensions.fav"); }
752
+ }
753
+ const from=String(out.spec || (hasData? "chara_card_v2" : "chara_card_v1"));
754
+ if(typeof out.data.name!=="string" || !out.data.name.trim()) throw new Error("不是可识别的卡片(没有名称,spec="+from+")");
755
+ out.spec="chara_card_v3";
756
+ out.spec_version="3.0";
757
+ return { card: out, from: from, copied: copied };
758
+ }
746
759
  function normalizeDraft(d){
747
760
  if(!d || typeof d!=="object") throw new Error("draft 必须是对象");
748
- if(!d.spec) throw new Error("缺少 spec");
749
- if(d.spec!=="chara_card_v3") throw new Error("spec 需为 chara_card_v3,当前 "+String(d.spec));
750
- if(!d.data || typeof d.data!=="object") throw new Error("缺少 data");
761
+ // V2/V1 一律**升级**而不是拒绝(B2);本来就是 V3 时 up.card 就是原对象
762
+ d=upgradeCardToV3(d).card;
751
763
  d.spec_version=d.spec_version||"3.0";
752
764
  d.data.character_book=d.data.character_book||{name:"",description:"",scan_depth:4,token_budget:1200,recursive_scanning:false,extensions:{},entries:[]};
753
765
  if(!Array.isArray(d.data.character_book.entries)) d.data.character_book.entries=[];
@@ -777,8 +789,7 @@ function b64DecodeBytes(b64){ return new Uint8Array(Buffer.from(String(b64),"bas
777
789
  function extractCardFromPngBytes(bytes){
778
790
  if(bytes.length<8 || bytes[0]!==0x89 || bytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
779
791
  let off=8;
780
- let found=null;
781
- let lastError=null;
792
+ let v3=null, v2=null, sawCardChunk=false, lastError=null;
782
793
  while(off+8 <= bytes.length){
783
794
  const len=(bytes[off]<<24)|(bytes[off+1]<<16)|(bytes[off+2]<<8)|bytes[off+3];
784
795
  if(len<0 || len> 20*1024*1024) break;
@@ -789,15 +800,19 @@ function extractCardFromPngBytes(bytes){
789
800
  try{
790
801
  const zero=data.indexOf(0);
791
802
  if(zero>=0){
792
- const keyword=bytesToStr(data.slice(0,zero));
793
- const text=bytesToStr(data.slice(zero+1));
794
- const kw=String(keyword).trim().toLowerCase();
803
+ const kw=String(bytesToStr(data.slice(0,zero))).trim().toLowerCase();
795
804
  if(kw==="ccv3" || kw==="ccv3_card" || kw==="chara" || kw==="chara_card_v3"){
796
- let jsonStr=text.trim();
805
+ sawCardChunk=true;
806
+ let jsonStr=bytesToStr(data.slice(zero+1)).trim();
797
807
  try{ const decoded=bytesToStr(b64DecodeBytes(jsonStr)); if(decoded.trim().startsWith("{")) jsonStr=decoded; }catch{}
798
808
  const obj=JSON.parse(jsonStr);
799
- if(obj && obj.spec){ found=obj; break; }
800
- if(obj && obj.data){ found={spec:"chara_card_v3",spec_version:"3.0",data:obj}; break; }
809
+ const cand=(obj && obj.spec)? obj : ((obj && obj.data)? {spec:"chara_card_v3",spec_version:"3.0",data:obj} : null);
810
+ // 判据用**载荷里的 spec**,不用块名:规范允许 `chara` 块里放回填用的 V2 对象,但现实里
811
+ // 也有工具把 V3 卡塞进 `chara`(我们自己历史上就写过 `chara_card_v3` 这个块名)。
812
+ if(cand && cand.data){
813
+ if(String(cand.spec)==="chara_card_v3"){ if(!v3) v3=cand; if(v3) break; }
814
+ else if(!v2) v2=cand;
815
+ }
801
816
  }
802
817
  }
803
818
  }catch(e){ lastError=e; }
@@ -807,33 +822,103 @@ function extractCardFromPngBytes(bytes){
807
822
  if(type==="IEND") break;
808
823
  off+=12+len;
809
824
  }
810
- if(!found) throw new Error(lastError? String(lastError.message):"PNG 未找到 ccv3/chara 块(tEXt ccv3),请确认是否为 CCv3 PNG");
811
- if(found.data) return found;
825
+ // 两个块都在时**必须**取 ccv3 —— 规范原文:「if the application detects both `chara` and `ccv3` chunk,
826
+ // the application *SHOULD* use the `ccv3` chunk」。SillyTavern 自己导出的 PNG 就是 chara 在前、ccv3 在后,
827
+ // 旧实现「取第一个命中的块」于是读到 V2 那份,再把 spec=chara_card_v2 的卡交给 normalizeDraft 直接报错。
828
+ if(v3) return v3;
829
+ // 只有 V2 块(ST 里那张卡的 `chara` 块、或只写过 chara 的老工具):照样交出去,
830
+ // 由 normalizeDraft → upgradeCardToV3 升级成 CCv3(B2:升级而不是拒绝)。
831
+ if(v2) return v2;
832
+ if(!sawCardChunk) throw new Error(lastError? String(lastError.message):"PNG 未找到 ccv3/chara 块(tEXt ccv3),请确认是否为 CCv3 PNG");
812
833
  throw new Error("PNG 内卡格式错误");
813
834
  }
814
- function buildTEXtChunk(cardJson){
835
+ // ========== SillyTavern / V2 兼容层(0.9.1)==========
836
+ // 原则:**底层永远是纯 CCv3**,兼容层只在「导出的那一份副本」上生成(草稿与库里的卡一个字节都不动)。
837
+ // 两个落点,都是规范允许的位置:
838
+ // ① PNG 的 `chara` 块 = 诚实的 CharacterCardV2 投影(见 pngCardChunks 注释);
839
+ // ② `data.extensions.world` = ST 的「角色↔世界书」绑定(ST 只认这个字符串,`characters.js:391,617`)。
840
+ // 规范明文禁止的是「往 CharacterCard 对象里加应用私有字段」(那张卡顶层加 V1 平铺字段就是这一类),所以
841
+ // 我们**不**学 ST 那么干 —— ST 需要的东西全部落在 `extensions` / `chara` 块 / CHARX 资源里。
842
+ /** V2 的 data 字段集(V3 独有的 nickname / creator_notes_multilingual / source / group_only_greetings /
843
+ * assets / creation_date / modification_date 一个都不进 —— 那些字段在 V2 里不存在)。 */
844
+ const V2_DATA_FIELDS=["name","description","personality","scenario","first_mes","mes_example","creator_notes",
845
+ "system_prompt","post_history_instructions","alternate_greetings","character_book","tags","creator","character_version","extensions"];
846
+ /** 摘掉正文**开头连续**的 `@@` 装饰器行(含其后遗留的空行)—— 规范要求回填 V2 时移除全部装饰器。 */
847
+ function stripDecorators(content){
848
+ const lines=String(content==null? "" : content).split("\n");
849
+ let i=0;
850
+ while(i<lines.length && String(lines[i]).slice(0,2)==="@@") i++;
851
+ while(i<lines.length && !String(lines[i]).trim()) i++;
852
+ return lines.slice(i).join("\n");
853
+ }
854
+ /** 整张卡 → CharacterCardV2 对象(PNG 的 `chara` 块用;只做投影,不改调用方传进来的对象)。 */
855
+ function stV2Projection(card){
856
+ const src=(card && typeof card==="object")? card : {};
857
+ const d=(src.data && typeof src.data==="object")? src.data : {};
858
+ const data={};
859
+ V2_DATA_FIELDS.forEach(function(k){
860
+ if(d[k]===undefined) return;
861
+ if(k==="character_book"){
862
+ const book=JSON.parse(JSON.stringify(d[k]));
863
+ if(book && Array.isArray(book.entries)) book.entries.forEach(function(e){ if(e && typeof e==="object") e.content=stripDecorators(e.content); });
864
+ data[k]=book;
865
+ return;
866
+ }
867
+ data[k]=d[k];
868
+ });
869
+ return { spec:"chara_card_v2", spec_version:"2.0", data };
870
+ }
871
+ /**
872
+ * 导出副本上写 ST 的世界书绑定。ST 的绑定就是 `data.extensions.world = <世界书文件名>`;
873
+ * 我们的共享书导出成 `<书名>.json`,所以这里直接写书名 —— 用户在 ST 里导完那本世界书,
874
+ * 再导这张卡,绑定就自动接上了(没导书时它只是个悬空字符串,ST 不会报错)。
875
+ * ⚠️ 只在导出副本上做(同「并入本卡」的家规):草稿与库里的卡不许被这个 ST 专有字段污染。
876
+ */
877
+ function withStWorld(card, world){
878
+ const name=String(world==null? "" : world).trim();
879
+ if(!name) return card;
880
+ const out=JSON.parse(JSON.stringify(card));
881
+ out.data=out.data||{};
882
+ out.data.extensions=Object.assign({}, out.data.extensions);
883
+ out.data.extensions.world=name;
884
+ return out;
885
+ }
886
+ function buildTEXtChunk(cardJson, keyword){
815
887
  const jsonStr=JSON.stringify(cardJson);
816
888
  const b64=Buffer.from(jsonStr,"utf8").toString("base64");
817
- const keyword=strToBytes("ccv3");
889
+ const kwBytes=strToBytes(keyword||"ccv3");
818
890
  const textBytes=strToBytes(b64);
819
- const chunkData=concatBytes([keyword, new Uint8Array([0]), textBytes]);
891
+ const chunkData=concatBytes([kwBytes, new Uint8Array([0]), textBytes]);
820
892
  const typeBytes=strToBytes("tEXt");
821
893
  const crc=crc32(concatBytes([typeBytes, chunkData]));
822
894
  return concatBytes([u32be(chunkData.length), typeBytes, chunkData, u32be(crc)]);
823
895
  }
896
+ /**
897
+ * 一张卡要写进 PNG 的两个 tEXt 块:`chara`(V2 投影,给只认 V2 的读取器回填)+ `ccv3`(纯 CCv3,规范里的 MUST)。
898
+ *
899
+ * 规范(SPEC_V3「PNG/APNG」节,0.9.1 实测对齐):
900
+ * ① `ccv3` 块 MUST 存在,值是 CharacterCardV3 对象的 utf-8→base64;
901
+ * ② 应用 MAY 用 `chara` 块回填 V2 对象;两个块都在时读取方 **SHOULD** 用 `ccv3`;
902
+ * ③ 「On backfilling V2, the application SHOULD remove all decorators」—— 所以投影里要摘掉 `@@` 装饰器行。
903
+ * SillyTavern 自己也是两个块都写(`src/character-card-parser.js:15-46`),且 `chara` 在前 —— 这里保持同样顺序。
904
+ * ⚠️ 卡片 JSON 的**顶层**绝不许加 ST 的私有字段(V1 平铺那套):规范同节写着应用 MUST follow the
905
+ * specification、私有数据只能放 `extensions` —— 兼容层只活在 `chara` 这一块与 `extensions.world` 里。
906
+ */
907
+ function pngCardChunks(cardJson){
908
+ return [buildTEXtChunk(stV2Projection(cardJson), "chara"), buildTEXtChunk(cardJson, "ccv3")];
909
+ }
824
910
  function buildPngWithCard(cardJson){
825
- const chunk=buildTEXtChunk(cardJson);
826
911
  const baseB64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
827
912
  const base=new Uint8Array(Buffer.from(baseB64,"base64"));
828
913
  const iendOff=base.length-12;
829
914
  const head=base.slice(0,iendOff);
830
915
  const tail=base.slice(iendOff);
831
- return concatBytes([head, chunk, tail]);
916
+ return concatBytes([head].concat(pngCardChunks(cardJson), [tail]));
832
917
  }
833
918
  function embedCardIntoPngBytes(pngBytes, cardJson){
834
919
  if(pngBytes.length<8 || pngBytes[0]!==0x89 || pngBytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
835
920
  if(pngBytes.length > 15*1024*1024) throw new Error("PNG 过大(>15MB)");
836
- const newChunk=buildTEXtChunk(cardJson);
921
+ const newChunks=pngCardChunks(cardJson);
837
922
  let off=8;
838
923
  const parts=[pngBytes.slice(0,8)];
839
924
  let inserted=false;
@@ -845,7 +930,7 @@ function embedCardIntoPngBytes(pngBytes, cardJson){
845
930
  const data=pngBytes.slice(off+8, off+8+len);
846
931
  const chunkBytes=pngBytes.slice(off, off+12+len);
847
932
  if(type==="IEND"){
848
- parts.push(newChunk);
933
+ newChunks.forEach(function(c){ parts.push(c); });
849
934
  parts.push(chunkBytes);
850
935
  inserted=true;
851
936
  break;
@@ -958,9 +1043,16 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
958
1043
  updatedAt: now,
959
1044
  };
960
1045
  }
961
- async function listLibraryEntries(){
1046
+ /**
1047
+ * 卡库快照(0.10.0):一次目录遍历 + 一次 parse,把 meta 与 draft **一起**留下。
1048
+ *
1049
+ * 以前 listLibraryEntries() 解析完整份卡 JSON 却只留 meta,于是「组体检」要对每张卡再读一遍盘
1050
+ * (N 张成员卡 = 2N 次读盘),而且拿不到 entries —— 卡组级检查(跨卡触发词冲突、五维漂移)根本做不了。
1051
+ * 现在读一次就够:listLibraryEntries() 只是它的投影,groupScope() 直接吃它。与预设半同构。
1052
+ */
1053
+ async function readLibrarySnapshot(){
962
1054
  await ensureLibDir();
963
- const seen = new Map(); // id → meta:先到先得,主根排在前 → 同名卡片主根优先
1055
+ const seen = new Map(); // id → {id, meta, draft, dir}:先到先得,主根排在前 → 同名卡片主根优先
964
1056
  for(const dir of libDirs()){
965
1057
  let files=[];
966
1058
  try{ files = await readdir(dir); }catch{ continue; }
@@ -973,17 +1065,22 @@ async function listLibraryEntries(){
973
1065
  const obj = JSON.parse(raw);
974
1066
  if(obj && obj.meta && obj.id){
975
1067
  // 兼容:meta.id 与文件名 id 一致
976
- seen.set(id, obj.meta);
1068
+ seen.set(id, { id:id, meta:obj.meta, draft:obj.draft || null, dir:dir });
977
1069
  } else if(obj && obj.draft){
978
1070
  // 旧格式兜底:从 draft 重建 meta
979
- seen.set(id, makeLibraryEntryMeta(obj.draft, null, id));
1071
+ seen.set(id, { id:id, meta:makeLibraryEntryMeta(obj.draft, null, id), draft:obj.draft, dir:dir });
980
1072
  }
981
1073
  }catch{}
982
1074
  }
983
1075
  }
984
- const entries = [...seen.values()];
985
- entries.sort((a,b)=> (b.updatedAt||0)-(a.updatedAt||0));
986
- return entries;
1076
+ const rows = [...seen.values()];
1077
+ rows.sort((a,b)=> ((b.meta.updatedAt||0)-(a.meta.updatedAt||0)));
1078
+ return rows;
1079
+ }
1080
+ /** 库条目 meta 列表(签名与排序语义未变,现在只是卡库快照的投影)。 */
1081
+ async function listLibraryEntries(){
1082
+ const rows = await readLibrarySnapshot();
1083
+ return rows.map(r => r.meta);
987
1084
  }
988
1085
  async function saveLibraryEntry(draft, opts){
989
1086
  const dir = await ensureLibDir();
@@ -1019,6 +1116,47 @@ async function groupMemberIds(groupId){
1019
1116
  const entries = await listLibraryEntries();
1020
1117
  return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
1021
1118
  }
1119
+ /**
1120
+ * 卡组作用域(0.10.0,与预设半同构)—— 取「哪个组、组里有哪些卡、这个组的书是什么」的**唯一入口**。
1121
+ *
1122
+ * 宿主半的来源卡来自 args.cardId 或草稿 sidecar(sessionId 对应的 origin.json);纯组装在
1123
+ * lib/group-layer.js(两半共用一份),所以界面看到的组与模型看到的组形状只有一种。
1124
+ * 拿不到来源卡 / 没进组 / 组不存在 / 组还没建书 → 返回 ok:false 的空作用域(调用方无需自己判空)。
1125
+ */
1126
+ async function groupScope(input){
1127
+ const opt = input || {};
1128
+ // 已经读过快照就传进来(opt.snapshot)—— 一次调用里只读一遍盘,也让「只读一次」可被测试钉住
1129
+ const snap = opt.snapshot ? opt.snapshot : await readLibrarySnapshot();
1130
+ const byId = new Map(snap.map(x => [String(x.id), x]));
1131
+ let cardId = opt.cardId ? String(opt.cardId) : "";
1132
+ if(!cardId && opt.sessionKey){
1133
+ const origin = readDraftOrigin(String(opt.sessionKey));
1134
+ cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
1135
+ }
1136
+ const mine = cardId ? (byId.get(cardId) || null) : null;
1137
+ const explicit = opt.groupId ? safeGroupId(opt.groupId) : "";
1138
+ const groupId = explicit || ((mine && mine.meta.groupId) ? String(mine.meta.groupId) : "");
1139
+ const base = { cardId: cardId, include: opt.include, maxCards: opt.maxCards, missing: (cardId && !mine) ? [cardId] : [] };
1140
+ if(!groupId) return assembleScope(Object.assign({ groupId: "" }, base));
1141
+ // ⚠️ 组文件缺失时**不提前返回**(与预设半同构):成员与共享书照样读出来,只是 group 为 null / ok:false ——
1142
+ // 「组没了但书还在」的历史数据,界面上仍应看得到那本书。
1143
+ const found = await readGroupRaw(groupId);
1144
+ const cards = snap.filter(x => String(x.meta.groupId || "") === groupId);
1145
+ const bf = await readBookRaw(groupId);
1146
+ const book = bf ? normalizeBook(bf.obj, groupId) : null;
1147
+ return assembleScope(Object.assign({
1148
+ groupId: groupId, group: found ? normalizeGroup(found.obj) : null, cards: cards, book: book,
1149
+ bookRev: book ? bookRevOf(book) : 0,
1150
+ }, base));
1151
+ }
1152
+ /**
1153
+ * 由卡组作用域算一次分段统计(0.10.0):体检类检查的数字来源,子类不再各自 for 循环求和。
1154
+ * 与预设半同构(那边是 groupScope + groupMetrics 的组合调用)。
1155
+ */
1156
+ function metricsForGroup(scope){
1157
+ return groupMetrics({ cardId: scope && scope.cardId, card: [],
1158
+ members: (scope && scope.members) || [], book: (scope && scope.book) ? scope.book.entries : [] });
1159
+ }
1022
1160
  /**
1023
1161
  * 按名字找组(建组/改名时查重):trim 后精确比对;带 exceptId 时排除自己(给自己改名不算撞车)。
1024
1162
  * 两半同构 —— 界面和模型必须落到同一条规则上,否则会出现「模型建不了同名组、界面却建了两个」。
@@ -1065,22 +1203,23 @@ async function assignCardToGroup(cardId, groupId){
1065
1203
  await patchLibraryMeta(cid, { groupId: gid });
1066
1204
  return { cardId: cid, cardName: mine.name, previousGroupId: (mine.groupId ? String(mine.groupId) : null), groupId: gid, nodeId: node.id, groupRev: g.rev, revived };
1067
1205
  }
1068
- /** 0.8.0:体检 / 渲染需要的卡片摘要(只读;卡不在库里就跳过)。 */
1206
+ /** 0.8.0:体检 / 渲染需要的卡片摘要(只读;卡不在库里就跳过)—— 0.10.0 起吃卡库快照,不再逐卡重复读盘。 */
1069
1207
  async function libraryCardsFor(ids){
1208
+ const rows = await readLibrarySnapshot();
1209
+ const byId = new Map(rows.map(r => [String(r.id), r]));
1070
1210
  const out = [];
1071
1211
  for(const id of (Array.isArray(ids) ? ids : [])){
1072
- try{
1073
- const obj = JSON.parse((await readLibraryRaw(id)).raw);
1074
- const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
1075
- const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
1076
- out.push({
1077
- id: String(id),
1078
- name: String(d.name || ""),
1079
- description: String(d.description || ""),
1080
- constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
1081
- tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
1082
- });
1083
- }catch{}
1212
+ const row = byId.get(String(id));
1213
+ if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
1214
+ const d = (row.draft && row.draft.data) ? row.draft.data : {};
1215
+ const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
1216
+ out.push({
1217
+ id: String(id),
1218
+ name: String(d.name || ""),
1219
+ description: String(d.description || ""),
1220
+ constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
1221
+ tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
1222
+ });
1084
1223
  }
1085
1224
  return out;
1086
1225
  }
@@ -1678,15 +1817,18 @@ function apply(ctx, config){
1678
1817
  return ok({ group: saved, applied: applied.applied, rejected: applied.rejected, changed: true });
1679
1818
  }
1680
1819
  case "cc_groupCheck":{
1681
- const found = await readGroupRaw(args.id);
1682
- if(!found) return fail("not-found", "分组不存在: " + String(args.id));
1683
- const g = normalizeGroup(found.obj);
1684
- const memberIds = await groupMemberIds(g.id);
1685
- const cards = await libraryCardsFor(memberIds);
1820
+ // 0.10.0:组 / 成员 / 共享书都从卡组作用域取(与预设半同一个入口、同一次读盘)。
1821
+ const scope = await groupScope({ groupId: args.id });
1822
+ if(!scope.ok || !scope.group) return fail("not-found", "分组不存在: " + String(args.id));
1823
+ const g = scope.group;
1824
+ const cards = await libraryCardsFor(scope.memberIds);
1686
1825
  // 0.9.0:把该组的共享世界书也端给体检 —— 它的 constant 条目在群聊里同样每轮插入
1687
- const bkFound = await readBookRaw(g.id);
1688
- const sharedEntries = bkFound ? normalizeBook(bkFound.obj, g.id).data.entries.filter(e => e && e.enabled !== false) : [];
1689
- return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name }, checkGroup(g, memberIds, cards, sharedEntries)));
1826
+ // 0.10.0:检查项走卡组层的注册表(structure / relations / budget / lore-quality / cross-card),
1827
+ // 用 args.checks 可只跑指定类别;返回带 byCheck 便于界面分组显示。
1828
+ const res = checkGroup(g, scope.memberIds, cards, scope.book ? scope.book.entries : [],
1829
+ { checks: args.checks, metrics: metricsForGroup(scope), scope: scope });
1830
+ return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name,
1831
+ checks: res.ran, byCheck: res.byCheck }, res));
1690
1832
  }
1691
1833
  case "cc_renderRelations":{
1692
1834
  // 渲染进**当前草稿**(与工具半共用同一份草稿文件);要落库仍需再存一次
@@ -1854,29 +1996,54 @@ function apply(ctx, config){
1854
1996
  const b64 = args.b64;
1855
1997
  if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
1856
1998
  if(b64.length > 20*1024*1024) return fail("too-large","PNG 过大(>20MB base64)");
1857
- let draft;
1999
+ let draft, upgradedFrom = null;
1858
2000
  try{
1859
2001
  const bytes = b64DecodeBytes(b64);
1860
2002
  if(bytes.length > 15*1024*1024) return fail("too-large","PNG 过大(>15MB)");
1861
2003
  draft = extractCardFromPngBytes(bytes);
2004
+ // B2:只有 V2 块时也能进(升级而不是拒绝)—— upgradedFrom 让界面把这件事说出来
2005
+ upgradedFrom = upgradeCardToV3(draft).from;
1862
2006
  draft = normalizeDraft(draft);
1863
2007
  }catch(e){ return fail("parse-failed", String(e.message)); }
1864
2008
  const k = draftKeyFrom(ctx, args);
1865
2009
  setDraftFor(k, draft);
1866
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
2010
+ return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
2011
+ }
2012
+ case "cc_importFromJson":{
2013
+ // 工坊拖进来的 .json 走这里(0.9.1,B2):V2/V1 卡**升级**成 CCv3,而不是让界面报「spec 不符」。
2014
+ // 客户端此前自己 parse + 只认 chara_card_v3 —— 于是 ST 里导出的卡一张都进不来。
2015
+ const raw = args.json;
2016
+ let obj = null;
2017
+ try{
2018
+ obj = (raw && typeof raw === "object")? raw : JSON.parse(String(raw==null? "" : raw));
2019
+ }catch(e){ return fail("parse-failed", "不是合法 JSON:"+String(e.message)); }
2020
+ // 自家草稿文件 / 导出包是 `{key, updatedAt, rev, draft}`,先脱一层
2021
+ if(obj && typeof obj==="object" && obj.draft && typeof obj.draft==="object" && !obj.data) obj = obj.draft;
2022
+ let draft, upgradedFrom = null;
2023
+ try{
2024
+ upgradedFrom = upgradeCardToV3(obj).from;
2025
+ draft = normalizeDraft(obj);
2026
+ }catch(e){ return fail("invalid-card", String(e.message)); }
2027
+ const k = draftKeyFrom(ctx, args);
2028
+ setDraftFor(k, draft);
2029
+ return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
1867
2030
  }
1868
2031
  case "cc_exportPng":{
1869
2032
  const k = draftKeyFrom(ctx, args);
1870
2033
  const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
1871
2034
  draft.data.modification_date = nowSec();
2035
+ // ST 兼容层(0.9.1):① 每个 PNG 都写两个块(ccv3 纯 CCv3 + chara 的 V2 投影,见 pngCardChunks);
2036
+ // ② stWorld 非空时,只在**导出副本**上写 `data.extensions.world`(ST 的角色↔世界书绑定)。
2037
+ // 返回给客户端的 draft 仍是「草稿本身」(多一个 ST 专有字段都不带),与「并入本卡」同一条家规。
2038
+ const exportCard = withStWorld(draft, args.stWorld);
1872
2039
  try{
1873
2040
  let pngBytes;
1874
2041
  if(args.imageB64 && typeof args.imageB64==="string" && args.imageB64.length>0){
1875
2042
  if(args.imageB64.length > 20*1024*1024) return fail("too-large","底图 PNG 过大(>20MB base64)");
1876
2043
  const baseBytes = b64DecodeBytes(args.imageB64);
1877
- pngBytes = embedCardIntoPngBytes(baseBytes, draft);
2044
+ pngBytes = embedCardIntoPngBytes(baseBytes, exportCard);
1878
2045
  } else {
1879
- pngBytes = buildPngWithCard(draft);
2046
+ pngBytes = buildPngWithCard(exportCard);
1880
2047
  }
1881
2048
  return ok({ b64: Buffer.from(pngBytes).toString("base64"), filename: (draft.data.name||"card")+".png", draft });
1882
2049
  }catch(e){ return fail("build-failed", String(e.message)); }
@@ -1885,7 +2052,7 @@ function apply(ctx, config){
1885
2052
  const b64 = args.b64;
1886
2053
  if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
1887
2054
  if(b64.length > 30*1024*1024) return fail("too-large","CHARX 过大(>30MB base64)");
1888
- let draft;
2055
+ let draft, upgradedFrom = null;
1889
2056
  try{
1890
2057
  const bytes = b64DecodeBytes(b64);
1891
2058
  if(bytes.length > 22*1024*1024) return fail("too-large","CHARX 过大(>22MB)");
@@ -1895,20 +2062,21 @@ function apply(ctx, config){
1895
2062
  if(!cardEntry) return fail("parse-failed","CHARX 未找到 card.json");
1896
2063
  const jsonStr = bytesToStr(cardEntry.data);
1897
2064
  const obj = JSON.parse(jsonStr);
1898
- draft = obj && obj.spec ? obj : (obj && obj.data ? obj : null);
1899
- if(!draft) throw new Error("card.json 格式错误");
1900
- draft = normalizeDraft(draft);
2065
+ upgradedFrom = upgradeCardToV3(obj).from;
2066
+ draft = normalizeDraft(obj);
1901
2067
  }catch(e){ return fail("parse-failed", String(e.message)); }
1902
2068
  const k = draftKeyFrom(ctx, args);
1903
2069
  setDraftFor(k, draft);
1904
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
2070
+ return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
1905
2071
  }
1906
2072
  case "cc_exportCharx":{
1907
2073
  const k = draftKeyFrom(ctx, args);
1908
2074
  const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
1909
2075
  draft.data.modification_date = nowSec();
2076
+ // ST 兼容层(0.9.1):只在**导出副本**上写世界书绑定,草稿/库里的卡不动(同「并入本卡」家规)
2077
+ const exportCard = withStWorld(draft, args.stWorld);
1910
2078
  try{
1911
- const zipBytes = buildZipCharx(draft);
2079
+ const zipBytes = buildZipCharx(exportCard);
1912
2080
  return ok({ b64: Buffer.from(zipBytes).toString("base64"), filename: (draft.data.name||"card")+".charx", draft });
1913
2081
  }catch(e){ return fail("build-failed", String(e.message)); }
1914
2082
  }
@@ -1976,6 +2144,14 @@ function apply(ctx, config){
1976
2144
  // 存储:<主根>/cc-books/<groupId>.json —— **一本书对一个分组**;文件本身就是可直接发布的
1977
2145
  // lorebook_v3 JSON(拷出去别人就能用),插件自己的状态放 `data.extensions.cc`(规范允许)。
1978
2146
  // 条目字段与卡内 `character_book.entries` **完全同构** —— 提升/回填都是零转换搬运。
2147
+ //
2148
+ // ⚠️ 0.9.1:这个文件是给**认 CCv3 的客户端**的,**SillyTavern 不认** —— ST 服务端
2149
+ // `/api/worldinfo/import` 拿到文件第一件事就是 `if (!('entries' in worldContent)) throw`
2150
+ // (用户实机:拖 `.lorebook.json` 进去 = `Error importing world info: … Bad Request`;
2151
+ // 1.19.0 的客户端 `importWorldInfo()` 也只转 Novel / Agnai / Risu,整份 `world-info.js`
2152
+ // 搜不到 `lorebook_v3`)。所以工坊导出页另给一枚「SillyTavern 世界书」:ST 原生形态
2153
+ // `{entries:{…}}`,条目字段按 ST 自己的 `convertCharacterBook()` 映射 —— 两枚按钮各有读者,
2154
+ // **别把其中一个说成「就是给客户端的通用格式」**(这正是 0.9.0 那轮写错边界的同一个毛病)。
1979
2155
  // ——————————————————————————————————————————————————————————————
1980
2156
  const BOOK_SPEC = "lorebook_v3";
1981
2157
  const BOOK_MAX_ENTRIES = 200;
@@ -2063,30 +2239,24 @@ async function deleteBook(groupId){
2063
2239
  return removed > 0;
2064
2240
  }
2065
2241
  /**
2066
- * 内容签名:折叠空白后取 djb2 哈希 + 长度,用于「同一条设定」的快速比对 —— 只差换行/空格的两条
2067
- * 不该被当成两条(实测里自动条目与手工条目就常差这点空白)。
2068
- * ⚠️ 它只是**索引**:真正搬运/删除前一律再逐字比对内容(哈希碰撞绝不能变成删数据)。
2242
+ * 内容签名:见 lib/group-layer.js 的 `contentKeyOf()`(只用来筛,动手前一律再逐字比对内容 ——
2243
+ * 哈希碰撞绝不能变成删数据)。
2069
2244
  */
2070
- function contentKeyOf(entry){
2071
- const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
2072
- let h = 5381;
2073
- for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
2074
- return "c" + h.toString(36) + "-" + s.length;
2075
- }
2076
- /** 成员卡的**完整**条目(去重要比内容,摘要不够)——与 libraryCardsFor 同源,多带 entries。 */
2245
+ /** 成员卡的**完整**条目(去重要比内容,摘要不够)—— 0.10.0 起吃卡库快照,不再逐卡重复读盘。 */
2077
2246
  async function bookCardsFor(ids){
2247
+ const rows = await readLibrarySnapshot();
2248
+ const byId = new Map(rows.map(r => [String(r.id), r]));
2078
2249
  const out = [];
2079
2250
  for(const id of (Array.isArray(ids) ? ids : [])){
2080
- try{
2081
- const obj = JSON.parse((await readLibraryRaw(id)).raw);
2082
- const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
2083
- const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
2084
- out.push({
2085
- id: String(id),
2086
- name: String(d.name || (obj && obj.meta && obj.meta.name) || ""),
2087
- entries: entries.map(makeBookEntry),
2088
- });
2089
- }catch{}
2251
+ const row = byId.get(String(id));
2252
+ if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
2253
+ const d = (row.draft && row.draft.data) ? row.draft.data : {};
2254
+ const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
2255
+ out.push({
2256
+ id: String(id),
2257
+ name: String(d.name || (row.meta && row.meta.name) || ""),
2258
+ entries: entries.map(makeBookEntry),
2259
+ });
2090
2260
  }
2091
2261
  return out;
2092
2262
  }
@@ -2111,49 +2281,10 @@ async function newBookFor(gid, explicitName){
2111
2281
  try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
2112
2282
  return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
2113
2283
  }
2114
- function scanSharedEntries(cards, bookEntries){
2115
- const list = Array.isArray(cards) ? cards : [];
2116
- const seen = new Map();
2117
- list.forEach(c => {
2118
- (Array.isArray(c.entries) ? c.entries : []).forEach(e => {
2119
- if(!String((e && e.content) || "").trim()) return; // 空内容不会进 prompt,不参与
2120
- const sig = contentKeyOf(e);
2121
- if(!seen.has(sig)) seen.set(sig, { signature: sig, content: String(e.content), name: String(e.name || ""), keys: (e.keys || []).map(String), constant: e.constant === true, chars: String(e.content).length, cards: [], cardNames: [], nameMismatch: false, keysMismatch: false });
2122
- const g = seen.get(sig);
2123
- if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
2124
- if(String(e.name || "") !== g.name) g.nameMismatch = true;
2125
- if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
2126
- });
2127
- });
2128
- const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
2129
- .sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
2130
- .map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
2131
- // 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
2132
- // 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
2133
- // 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
2134
- const bookDupes = [];
2135
- (Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
2136
- const content = String((be && be.content) == null ? "" : be.content);
2137
- const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
2138
- if(!g || g.content !== content) return;
2139
- bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
2140
- content, keys: (be.keys || []).map(String), chars: content.length,
2141
- cards: g.cards.slice(), cardNames: g.cardNames.slice(),
2142
- preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
2143
- });
2144
- const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
2145
- return {
2146
- candidates, bookDupes,
2147
- stats: {
2148
- cards: list.length, totalEntries,
2149
- duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
2150
- savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
2151
- bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
2152
- bookDupeCount: bookDupes.length,
2153
- bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0),
2154
- },
2155
- };
2156
- }
2284
+ /**
2285
+ * 跨卡重复扫描:见 lib/group-layer.js 的 `scanSharedEntries()`(0.10.0 起两半共用一份实现)。
2286
+ * 签名只用来筛,判等一律再逐字比对内容 —— 哈希碰撞绝不能变成搬错数据。
2287
+ */
2157
2288
  /** 把共享书的条目合并进一张卡的条目表(同内容已存在则跳过)—— 给不支持全局世界书的客户端兜底。 */
2158
2289
  function mergeBookEntries(bookEntries, cardEntries){
2159
2290
  const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
@@ -2197,6 +2328,8 @@ function applyBookPromotion(cards, bookEntries, signatures){
2197
2328
 
2198
2329
  export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclaredPresets, presetTargetRoot, presetSourceRoot,
2199
2330
  draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision,
2331
+ // 卡组层(0.10.0):卡库快照 + 卡组作用域 —— 卡组数据的唯一入口,两半共用 lib/group-layer.js 的纯组装
2332
+ readLibrarySnapshot, groupScope,
2200
2333
  // 落盘根与 lib 层 CRUD(供回归测试直接断言「DSH_HOME 优先 + 读时回退」)
2201
2334
  dshHomeDir, legacyDshHomeDir, dataRoots, draftDirs, draftDir, draftPath, findDraftPath, safeDraftFile,
2202
2335
  persistDraftSync, loadPersistedDraftSync, libDirs, libDir, getDraftFor, setDraftFor, cardFingerprint, normalizeCardShape,
@@ -2212,6 +2345,10 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
2212
2345
  // 0.9.0 组级共享世界书:落盘 + 纯函数(扫描/提升/回填),两半同构、条目与卡内 character_book 同形
2213
2346
  BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
2214
2347
  contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
2348
+ // 0.9.1 SillyTavern 兼容层:PNG 双块(chara 的 V2 投影 + 纯 CCv3)、ccv3 优先读取、ST 世界书绑定
2349
+ stV2Projection, stripDecorators, withStWorld, pngCardChunks, buildTEXtChunk, buildPngWithCard, embedCardIntoPngBytes, extractCardFromPngBytes,
2350
+ // 0.9.1 B2:V2/V1 卡升级成 CCv3(PNG/CHARX/JSON 三条导入路径共用这一份实现)
2351
+ upgradeCardToV3, normTags,
2215
2352
  REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
2216
2353
 
2217
2354