@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/lib/index.js CHANGED
@@ -622,7 +622,7 @@ function spliceRelations(description, section, meta){
622
622
  * 跨卡体检(纯函数)。members / cards 由调用方给:
623
623
  * members = 组内卡的 id 列表;cards = [{id, name, description, constantTokens, tokenBudget}]
624
624
  */
625
- function checkGroup(group, members, cards){
625
+ function checkGroup(group, members, cards, sharedEntries){
626
626
  const g = normalizeGroup(group);
627
627
  const issues = [];
628
628
  const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
@@ -655,12 +655,21 @@ function checkGroup(group, members, cards){
655
655
  if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
656
656
  if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
657
657
  // 群聊版的「单次触发」陷阱:每张卡的 constant 每轮都进上下文,卡越多固定开销越大
658
- const constTokens = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
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;
659
665
  const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
660
- if(memberCards.length > 1 && constTokens > minBudget * 0.8){
661
- add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻条目合计约 " + constTokens + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
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) + "%)");
662
670
  }
663
- return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length, constantTokens: constTokens, minBudget } };
671
+ return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
672
+ constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget } };
664
673
  }
665
674
 
666
675
  /** 读分组:主根优先,回退旧根(与角色库同规)。 */
@@ -734,11 +743,65 @@ async function readLibraryRaw(id){
734
743
  throw lastErr || new Error("找不到角色卡: " + String(id));
735
744
  }
736
745
  // ========== PNG / CHARX 共享 ==========
746
+ /** tags 在 V1/ST 那边可能是逗号分隔的字符串;V2/V3 要数组。 */
747
+ function normTags(v){
748
+ if(Array.isArray(v)) return v.map(function(x){ return String(x); });
749
+ if(typeof v==="string") return v.split(",").map(function(s){ return s.trim(); }).filter(Boolean);
750
+ return [];
751
+ }
752
+ /**
753
+ * V2(乃至 V1)卡 → CCv3 升级(0.9.1 的 B2)。返回 `{card, from}`,`from` 为 null = 本来就是 V3。
754
+ *
755
+ * 为什么做:SillyTavern 里**绝大多数存量卡是 V2**(ST 界面新建的就是 V2 形状,ST 只是把 `spec` 改写成
756
+ * 了 v3 标签),而工坊此前只收 `chara_card_v3` —— 用户把 ST 里那张卡的 PNG/JSON 拖进来,只会得到
757
+ * 一句「spec 需为 chara_card_v3」。升级规则照 ST 自己的 `readFromV2()`(`characters.js:504-556`):
758
+ * 顶层那些 V1 平铺字段并进 `data`(**只在 `data` 里没有该值时**,不覆盖 V2 自己的值),其余不认识
759
+ * 的字段原样留着。`from` 交给调用方,用来**明确告诉用户「这份是升上来的」**(不静默变换)。
760
+ */
761
+ function upgradeCardToV3(obj){
762
+ if(!obj || typeof obj!=="object") throw new Error("卡必须是对象");
763
+ if(obj.spec==="chara_card_v3" && obj.data && typeof obj.data==="object") return { card: obj, from: null, copied: [] };
764
+ const out=JSON.parse(JSON.stringify(obj));
765
+ const copied=[];
766
+ const hasData=!!(out.data && typeof out.data==="object");
767
+ if(!hasData){
768
+ // 纯 V1:整份包成 data
769
+ const flat={};
770
+ ["name","description","personality","scenario","first_mes","mes_example"].forEach(function(k){ if(out[k]!==undefined) flat[k]=out[k]; });
771
+ flat.creator_notes = out.creator_notes!==undefined? out.creator_notes : (out.creatorcomment!==undefined? out.creatorcomment : "");
772
+ flat.system_prompt="";
773
+ flat.post_history_instructions="";
774
+ flat.alternate_greetings=Array.isArray(out.alternate_greetings)? out.alternate_greetings : [];
775
+ flat.tags=normTags(out.tags);
776
+ flat.creator=out.creator!==undefined? out.creator : "";
777
+ flat.character_version=out.character_version!==undefined? out.character_version : "";
778
+ flat.extensions={};
779
+ if(out.talkativeness!==undefined) flat.extensions.talkativeness=out.talkativeness;
780
+ if(out.fav!==undefined) flat.extensions.fav=out.fav===true;
781
+ out.data=flat;
782
+ } else {
783
+ out.data.extensions=(out.data.extensions && typeof out.data.extensions==="object")? out.data.extensions : {};
784
+ ["name","description","personality","scenario","first_mes","mes_example"].forEach(function(k){
785
+ if(out.data[k]===undefined && out[k]!==undefined){ out.data[k]=out[k]; copied.push("data."+k); }
786
+ });
787
+ if(out.data.tags===undefined && out.tags!==undefined){ out.data.tags=normTags(out.tags); copied.push("data.tags"); }
788
+ if(out.data.creator_notes===undefined){
789
+ if(out.creator_notes!==undefined){ out.data.creator_notes=out.creator_notes; copied.push("data.creator_notes"); }
790
+ else if(out.creatorcomment!==undefined){ out.data.creator_notes=out.creatorcomment; copied.push("data.creator_notes"); }
791
+ }
792
+ if(out.talkativeness!==undefined && out.data.extensions.talkativeness===undefined){ out.data.extensions.talkativeness=out.talkativeness; copied.push("data.extensions.talkativeness"); }
793
+ if(out.fav!==undefined && out.data.extensions.fav===undefined){ out.data.extensions.fav=out.fav===true; copied.push("data.extensions.fav"); }
794
+ }
795
+ const from=String(out.spec || (hasData? "chara_card_v2" : "chara_card_v1"));
796
+ if(typeof out.data.name!=="string" || !out.data.name.trim()) throw new Error("不是可识别的卡片(没有名称,spec="+from+")");
797
+ out.spec="chara_card_v3";
798
+ out.spec_version="3.0";
799
+ return { card: out, from: from, copied: copied };
800
+ }
737
801
  function normalizeDraft(d){
738
802
  if(!d || typeof d!=="object") throw new Error("draft 必须是对象");
739
- if(!d.spec) throw new Error("缺少 spec");
740
- if(d.spec!=="chara_card_v3") throw new Error("spec 需为 chara_card_v3,当前 "+String(d.spec));
741
- if(!d.data || typeof d.data!=="object") throw new Error("缺少 data");
803
+ // V2/V1 一律**升级**而不是拒绝(B2);本来就是 V3 时 up.card 就是原对象
804
+ d=upgradeCardToV3(d).card;
742
805
  d.spec_version=d.spec_version||"3.0";
743
806
  d.data.character_book=d.data.character_book||{name:"",description:"",scan_depth:4,token_budget:1200,recursive_scanning:false,extensions:{},entries:[]};
744
807
  if(!Array.isArray(d.data.character_book.entries)) d.data.character_book.entries=[];
@@ -768,8 +831,7 @@ function b64DecodeBytes(b64){ return new Uint8Array(Buffer.from(String(b64),"bas
768
831
  function extractCardFromPngBytes(bytes){
769
832
  if(bytes.length<8 || bytes[0]!==0x89 || bytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
770
833
  let off=8;
771
- let found=null;
772
- let lastError=null;
834
+ let v3=null, v2=null, sawCardChunk=false, lastError=null;
773
835
  while(off+8 <= bytes.length){
774
836
  const len=(bytes[off]<<24)|(bytes[off+1]<<16)|(bytes[off+2]<<8)|bytes[off+3];
775
837
  if(len<0 || len> 20*1024*1024) break;
@@ -780,15 +842,19 @@ function extractCardFromPngBytes(bytes){
780
842
  try{
781
843
  const zero=data.indexOf(0);
782
844
  if(zero>=0){
783
- const keyword=bytesToStr(data.slice(0,zero));
784
- const text=bytesToStr(data.slice(zero+1));
785
- const kw=String(keyword).trim().toLowerCase();
845
+ const kw=String(bytesToStr(data.slice(0,zero))).trim().toLowerCase();
786
846
  if(kw==="ccv3" || kw==="ccv3_card" || kw==="chara" || kw==="chara_card_v3"){
787
- let jsonStr=text.trim();
847
+ sawCardChunk=true;
848
+ let jsonStr=bytesToStr(data.slice(zero+1)).trim();
788
849
  try{ const decoded=bytesToStr(b64DecodeBytes(jsonStr)); if(decoded.trim().startsWith("{")) jsonStr=decoded; }catch{}
789
850
  const obj=JSON.parse(jsonStr);
790
- if(obj && obj.spec){ found=obj; break; }
791
- if(obj && obj.data){ found={spec:"chara_card_v3",spec_version:"3.0",data:obj}; break; }
851
+ const cand=(obj && obj.spec)? obj : ((obj && obj.data)? {spec:"chara_card_v3",spec_version:"3.0",data:obj} : null);
852
+ // 判据用**载荷里的 spec**,不用块名:规范允许 `chara` 块里放回填用的 V2 对象,但现实里
853
+ // 也有工具把 V3 卡塞进 `chara`(我们自己历史上就写过 `chara_card_v3` 这个块名)。
854
+ if(cand && cand.data){
855
+ if(String(cand.spec)==="chara_card_v3"){ if(!v3) v3=cand; if(v3) break; }
856
+ else if(!v2) v2=cand;
857
+ }
792
858
  }
793
859
  }
794
860
  }catch(e){ lastError=e; }
@@ -798,33 +864,103 @@ function extractCardFromPngBytes(bytes){
798
864
  if(type==="IEND") break;
799
865
  off+=12+len;
800
866
  }
801
- if(!found) throw new Error(lastError? String(lastError.message):"PNG 未找到 ccv3/chara 块(tEXt ccv3),请确认是否为 CCv3 PNG");
802
- if(found.data) return found;
867
+ // 两个块都在时**必须**取 ccv3 —— 规范原文:「if the application detects both `chara` and `ccv3` chunk,
868
+ // the application *SHOULD* use the `ccv3` chunk」。SillyTavern 自己导出的 PNG 就是 chara 在前、ccv3 在后,
869
+ // 旧实现「取第一个命中的块」于是读到 V2 那份,再把 spec=chara_card_v2 的卡交给 normalizeDraft 直接报错。
870
+ if(v3) return v3;
871
+ // 只有 V2 块(ST 里那张卡的 `chara` 块、或只写过 chara 的老工具):照样交出去,
872
+ // 由 normalizeDraft → upgradeCardToV3 升级成 CCv3(B2:升级而不是拒绝)。
873
+ if(v2) return v2;
874
+ if(!sawCardChunk) throw new Error(lastError? String(lastError.message):"PNG 未找到 ccv3/chara 块(tEXt ccv3),请确认是否为 CCv3 PNG");
803
875
  throw new Error("PNG 内卡格式错误");
804
876
  }
805
- function buildTEXtChunk(cardJson){
877
+ // ========== SillyTavern / V2 兼容层(0.9.1)==========
878
+ // 原则:**底层永远是纯 CCv3**,兼容层只在「导出的那一份副本」上生成(草稿与库里的卡一个字节都不动)。
879
+ // 两个落点,都是规范允许的位置:
880
+ // ① PNG 的 `chara` 块 = 诚实的 CharacterCardV2 投影(见 pngCardChunks 注释);
881
+ // ② `data.extensions.world` = ST 的「角色↔世界书」绑定(ST 只认这个字符串,`characters.js:391,617`)。
882
+ // 规范明文禁止的是「往 CharacterCard 对象里加应用私有字段」(那张卡顶层加 V1 平铺字段就是这一类),所以
883
+ // 我们**不**学 ST 那么干 —— ST 需要的东西全部落在 `extensions` / `chara` 块 / CHARX 资源里。
884
+ /** V2 的 data 字段集(V3 独有的 nickname / creator_notes_multilingual / source / group_only_greetings /
885
+ * assets / creation_date / modification_date 一个都不进 —— 那些字段在 V2 里不存在)。 */
886
+ const V2_DATA_FIELDS=["name","description","personality","scenario","first_mes","mes_example","creator_notes",
887
+ "system_prompt","post_history_instructions","alternate_greetings","character_book","tags","creator","character_version","extensions"];
888
+ /** 摘掉正文**开头连续**的 `@@` 装饰器行(含其后遗留的空行)—— 规范要求回填 V2 时移除全部装饰器。 */
889
+ function stripDecorators(content){
890
+ const lines=String(content==null? "" : content).split("\n");
891
+ let i=0;
892
+ while(i<lines.length && String(lines[i]).slice(0,2)==="@@") i++;
893
+ while(i<lines.length && !String(lines[i]).trim()) i++;
894
+ return lines.slice(i).join("\n");
895
+ }
896
+ /** 整张卡 → CharacterCardV2 对象(PNG 的 `chara` 块用;只做投影,不改调用方传进来的对象)。 */
897
+ function stV2Projection(card){
898
+ const src=(card && typeof card==="object")? card : {};
899
+ const d=(src.data && typeof src.data==="object")? src.data : {};
900
+ const data={};
901
+ V2_DATA_FIELDS.forEach(function(k){
902
+ if(d[k]===undefined) return;
903
+ if(k==="character_book"){
904
+ const book=JSON.parse(JSON.stringify(d[k]));
905
+ if(book && Array.isArray(book.entries)) book.entries.forEach(function(e){ if(e && typeof e==="object") e.content=stripDecorators(e.content); });
906
+ data[k]=book;
907
+ return;
908
+ }
909
+ data[k]=d[k];
910
+ });
911
+ return { spec:"chara_card_v2", spec_version:"2.0", data };
912
+ }
913
+ /**
914
+ * 导出副本上写 ST 的世界书绑定。ST 的绑定就是 `data.extensions.world = <世界书文件名>`;
915
+ * 我们的共享书导出成 `<书名>.json`,所以这里直接写书名 —— 用户在 ST 里导完那本世界书,
916
+ * 再导这张卡,绑定就自动接上了(没导书时它只是个悬空字符串,ST 不会报错)。
917
+ * ⚠️ 只在导出副本上做(同「并入本卡」的家规):草稿与库里的卡不许被这个 ST 专有字段污染。
918
+ */
919
+ function withStWorld(card, world){
920
+ const name=String(world==null? "" : world).trim();
921
+ if(!name) return card;
922
+ const out=JSON.parse(JSON.stringify(card));
923
+ out.data=out.data||{};
924
+ out.data.extensions=Object.assign({}, out.data.extensions);
925
+ out.data.extensions.world=name;
926
+ return out;
927
+ }
928
+ function buildTEXtChunk(cardJson, keyword){
806
929
  const jsonStr=JSON.stringify(cardJson);
807
930
  const b64=Buffer.from(jsonStr,"utf8").toString("base64");
808
- const keyword=strToBytes("ccv3");
931
+ const kwBytes=strToBytes(keyword||"ccv3");
809
932
  const textBytes=strToBytes(b64);
810
- const chunkData=concatBytes([keyword, new Uint8Array([0]), textBytes]);
933
+ const chunkData=concatBytes([kwBytes, new Uint8Array([0]), textBytes]);
811
934
  const typeBytes=strToBytes("tEXt");
812
935
  const crc=crc32(concatBytes([typeBytes, chunkData]));
813
936
  return concatBytes([u32be(chunkData.length), typeBytes, chunkData, u32be(crc)]);
814
937
  }
938
+ /**
939
+ * 一张卡要写进 PNG 的两个 tEXt 块:`chara`(V2 投影,给只认 V2 的读取器回填)+ `ccv3`(纯 CCv3,规范里的 MUST)。
940
+ *
941
+ * 规范(SPEC_V3「PNG/APNG」节,0.9.1 实测对齐):
942
+ * ① `ccv3` 块 MUST 存在,值是 CharacterCardV3 对象的 utf-8→base64;
943
+ * ② 应用 MAY 用 `chara` 块回填 V2 对象;两个块都在时读取方 **SHOULD** 用 `ccv3`;
944
+ * ③ 「On backfilling V2, the application SHOULD remove all decorators」—— 所以投影里要摘掉 `@@` 装饰器行。
945
+ * SillyTavern 自己也是两个块都写(`src/character-card-parser.js:15-46`),且 `chara` 在前 —— 这里保持同样顺序。
946
+ * ⚠️ 卡片 JSON 的**顶层**绝不许加 ST 的私有字段(V1 平铺那套):规范同节写着应用 MUST follow the
947
+ * specification、私有数据只能放 `extensions` —— 兼容层只活在 `chara` 这一块与 `extensions.world` 里。
948
+ */
949
+ function pngCardChunks(cardJson){
950
+ return [buildTEXtChunk(stV2Projection(cardJson), "chara"), buildTEXtChunk(cardJson, "ccv3")];
951
+ }
815
952
  function buildPngWithCard(cardJson){
816
- const chunk=buildTEXtChunk(cardJson);
817
953
  const baseB64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
818
954
  const base=new Uint8Array(Buffer.from(baseB64,"base64"));
819
955
  const iendOff=base.length-12;
820
956
  const head=base.slice(0,iendOff);
821
957
  const tail=base.slice(iendOff);
822
- return concatBytes([head, chunk, tail]);
958
+ return concatBytes([head].concat(pngCardChunks(cardJson), [tail]));
823
959
  }
824
960
  function embedCardIntoPngBytes(pngBytes, cardJson){
825
961
  if(pngBytes.length<8 || pngBytes[0]!==0x89 || pngBytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
826
962
  if(pngBytes.length > 15*1024*1024) throw new Error("PNG 过大(>15MB)");
827
- const newChunk=buildTEXtChunk(cardJson);
963
+ const newChunks=pngCardChunks(cardJson);
828
964
  let off=8;
829
965
  const parts=[pngBytes.slice(0,8)];
830
966
  let inserted=false;
@@ -836,7 +972,7 @@ function embedCardIntoPngBytes(pngBytes, cardJson){
836
972
  const data=pngBytes.slice(off+8, off+8+len);
837
973
  const chunkBytes=pngBytes.slice(off, off+12+len);
838
974
  if(type==="IEND"){
839
- parts.push(newChunk);
975
+ newChunks.forEach(function(c){ parts.push(c); });
840
976
  parts.push(chunkBytes);
841
977
  inserted=true;
842
978
  break;
@@ -1661,7 +1797,7 @@ function apply(ctx, config){
1661
1797
  if(!found) return fail("not-found", "分组不存在: " + String(args.id));
1662
1798
  const cur = normalizeGroup(found.obj);
1663
1799
  if(typeof args.expectedRev === "number" && args.expectedRev !== cur.rev){
1664
- return fail("stale-rev", "分组已被外部修改(期望 rev=" + args.expectedRev + ",实际 rev=" + cur.rev + ")—— 先重新读取再改", { staleRev: true, expectedRev: args.expectedRev, actualRev: cur.rev });
1800
+ return fail("stale-rev", "分组已被外部修改(期望 rev=" + args.expectedRev + ",实际 rev=" + cur.rev + ")—— 先重新读取再改", { staleGroupRev: true, expectedRev: args.expectedRev, actualRev: cur.rev });
1665
1801
  }
1666
1802
  const applied = applyGroupOps(cur, args.ops);
1667
1803
  if(!applied.applied.length) return ok({ group: cur, applied: [], rejected: applied.rejected, changed: false });
@@ -1674,7 +1810,10 @@ function apply(ctx, config){
1674
1810
  const g = normalizeGroup(found.obj);
1675
1811
  const memberIds = await groupMemberIds(g.id);
1676
1812
  const cards = await libraryCardsFor(memberIds);
1677
- return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name }, checkGroup(g, memberIds, cards)));
1813
+ // 0.9.0:把该组的共享世界书也端给体检 —— 它的 constant 条目在群聊里同样每轮插入
1814
+ const bkFound = await readBookRaw(g.id);
1815
+ const sharedEntries = bkFound ? normalizeBook(bkFound.obj, g.id).data.entries.filter(e => e && e.enabled !== false) : [];
1816
+ return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name }, checkGroup(g, memberIds, cards, sharedEntries)));
1678
1817
  }
1679
1818
  case "cc_renderRelations":{
1680
1819
  // 渲染进**当前草稿**(与工具半共用同一份草稿文件);要落库仍需再存一次
@@ -1710,10 +1849,11 @@ function apply(ctx, config){
1710
1849
  if(!gid) return fail("invalid-group", "需要 groupId");
1711
1850
  const found = await readBookRaw(gid);
1712
1851
  const cards = await bookCardsFor(await groupMemberIds(gid));
1713
- const scan = scanSharedEntries(cards);
1852
+ const book = found ? normalizeBook(found.obj, gid) : null;
1853
+ // 把书一起交给 scan → 认得出「卡内条目与共享书逐字重复」那一类(报告 P1)
1854
+ const scan = scanSharedEntries(cards, book ? book.data.entries : []);
1714
1855
  const cardList = cards.map(c => ({ id: c.id, name: c.name, entries: (c.entries || []).length }));
1715
1856
  if(!found) return ok({ exists: false, groupId: gid, cards: cardList, scan });
1716
- const book = normalizeBook(found.obj, gid);
1717
1857
  return ok({ exists: true, groupId: gid, book, rev: bookRevOf(book),
1718
1858
  tokens: estimateTokensLite(book.data.entries.map(e => e.content).join("\n")),
1719
1859
  cards: cardList, scan });
@@ -1722,10 +1862,10 @@ function apply(ctx, config){
1722
1862
  const gid = safeGroupId(args.groupId);
1723
1863
  if(!gid) return fail("invalid-group", "需要 groupId");
1724
1864
  const found = await readBookRaw(gid);
1725
- const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({ data: { name: String(args.name || "共享设定") } }, gid);
1865
+ const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid, args.name);
1726
1866
  if(typeof args.expectedBookRev === "number" && args.expectedBookRev !== bookRevOf(cur)){
1727
1867
  return fail("stale-rev", "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先重新读取再改",
1728
- { staleRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur) });
1868
+ { staleBookRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur) });
1729
1869
  }
1730
1870
  const applied = [], rejected = [];
1731
1871
  if(args.name !== undefined) cur.data.name = String(args.name).slice(0, 80);
@@ -1771,20 +1911,27 @@ function apply(ctx, config){
1771
1911
  const gid = safeGroupId(args.groupId);
1772
1912
  if(!gid) return fail("invalid-group", "需要 groupId");
1773
1913
  const found = await readBookRaw(gid);
1774
- const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({}, gid);
1914
+ const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid); // 默认名 `<组名>·共享设定`(报告 4.2)
1775
1915
  const cards = await bookCardsFor(await groupMemberIds(gid));
1776
- const scan = scanSharedEntries(cards);
1777
- const known = new Map(scan.candidates.map(c => [c.signature, c]));
1916
+ // 候选两类:卡间重复(提升进书)+ 卡内条目与共享书逐字重复(书里已有,只清卡内副本)—— 见 agent 半同一处注释
1917
+ const scan = scanSharedEntries(cards, cur.data.entries);
1918
+ const known = new Map(scan.candidates.concat(scan.bookDupes).map(c => [c.signature, c]));
1778
1919
  // 只认得扫描出来的候选:传了不存在的签名一律拒绝 —— 这是会**从多张卡里删条目**的操作,绝不猜
1779
- const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String) : scan.candidates.map(c => c.signature);
1920
+ const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String)
1921
+ : scan.candidates.map(c => c.signature).concat(scan.bookDupes.map(c => c.signature));
1780
1922
  const unknownKeys = requested.filter(s => !known.has(s));
1781
1923
  const use = requested.filter(s => known.has(s));
1924
+ const bookSigs = new Set(cur.data.entries.map(contentKeyOf));
1925
+ const promoteSigs = use.filter(s => !bookSigs.has(s));
1926
+ const cleanSigs = use.filter(s => bookSigs.has(s));
1782
1927
  const plan = applyBookPromotion(cards, cur.data.entries, use);
1783
- const summary = (g) => ({ signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames, chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch, keysMismatch: g.keysMismatch, preview: g.preview });
1928
+ const summary = (g) => ({ kind: g.kind || "card-vs-card", signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames,
1929
+ chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch === true, keysMismatch: g.keysMismatch === true, preview: g.preview });
1784
1930
  if(args.apply !== true){
1785
- return ok({ dryRun: true, changed: false, wouldPromote: use.length, unknownKeys,
1786
- removedTotal: plan.removedTotal, perCard: plan.cards.map(c => ({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length })),
1787
- candidates: scan.candidates.map(summary), stats: scan.stats, rev: bookRevOf(cur) });
1931
+ return ok({ dryRun: true, changed: false, wouldPromote: promoteSigs.length, wouldClean: cleanSigs.length, unknownKeys,
1932
+ removedTotal: plan.removedTotal, savedChars: plan.savedChars,
1933
+ perCard: plan.cards.map(c => ({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length })),
1934
+ candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats, rev: bookRevOf(cur) });
1788
1935
  }
1789
1936
  const bookNext = Object.assign({}, cur, { data: Object.assign({}, cur.data, { entries: plan.bookEntries }) });
1790
1937
  const saved = await writeBook(gid, bookNext);
@@ -1800,9 +1947,11 @@ function apply(ctx, config){
1800
1947
  touched.push({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length });
1801
1948
  }catch(e){ touched.push({ id: c.id, name: c.name, error: String(e && e.message || e) }); }
1802
1949
  }
1803
- return ok({ dryRun: false, changed: true, promoted: plan.promoted.length, removedTotal: plan.removedTotal,
1950
+ const promotedCopies = cards.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.filter(e => promoteSigs.indexOf(contentKeyOf(e)) >= 0).length : 0), 0);
1951
+ const cleaned = Math.max(0, plan.removedTotal - promotedCopies);
1952
+ return ok({ dryRun: false, changed: true, promoted: plan.promoted.length, cleaned, removedTotal: plan.removedTotal,
1804
1953
  unknownKeys, bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched,
1805
- candidates: scan.candidates.map(summary), stats: scan.stats });
1954
+ candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats });
1806
1955
  }
1807
1956
  case "cc_applyBook":{
1808
1957
  const gid = safeGroupId(args.groupId);
@@ -1832,29 +1981,54 @@ function apply(ctx, config){
1832
1981
  const b64 = args.b64;
1833
1982
  if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
1834
1983
  if(b64.length > 20*1024*1024) return fail("too-large","PNG 过大(>20MB base64)");
1835
- let draft;
1984
+ let draft, upgradedFrom = null;
1836
1985
  try{
1837
1986
  const bytes = b64DecodeBytes(b64);
1838
1987
  if(bytes.length > 15*1024*1024) return fail("too-large","PNG 过大(>15MB)");
1839
1988
  draft = extractCardFromPngBytes(bytes);
1989
+ // B2:只有 V2 块时也能进(升级而不是拒绝)—— upgradedFrom 让界面把这件事说出来
1990
+ upgradedFrom = upgradeCardToV3(draft).from;
1840
1991
  draft = normalizeDraft(draft);
1841
1992
  }catch(e){ return fail("parse-failed", String(e.message)); }
1842
1993
  const k = draftKeyFrom(ctx, args);
1843
1994
  setDraftFor(k, draft);
1844
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
1995
+ return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
1996
+ }
1997
+ case "cc_importFromJson":{
1998
+ // 工坊拖进来的 .json 走这里(0.9.1,B2):V2/V1 卡**升级**成 CCv3,而不是让界面报「spec 不符」。
1999
+ // 客户端此前自己 parse + 只认 chara_card_v3 —— 于是 ST 里导出的卡一张都进不来。
2000
+ const raw = args.json;
2001
+ let obj = null;
2002
+ try{
2003
+ obj = (raw && typeof raw === "object")? raw : JSON.parse(String(raw==null? "" : raw));
2004
+ }catch(e){ return fail("parse-failed", "不是合法 JSON:"+String(e.message)); }
2005
+ // 自家草稿文件 / 导出包是 `{key, updatedAt, rev, draft}`,先脱一层
2006
+ if(obj && typeof obj==="object" && obj.draft && typeof obj.draft==="object" && !obj.data) obj = obj.draft;
2007
+ let draft, upgradedFrom = null;
2008
+ try{
2009
+ upgradedFrom = upgradeCardToV3(obj).from;
2010
+ draft = normalizeDraft(obj);
2011
+ }catch(e){ return fail("invalid-card", String(e.message)); }
2012
+ const k = draftKeyFrom(ctx, args);
2013
+ setDraftFor(k, draft);
2014
+ return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
1845
2015
  }
1846
2016
  case "cc_exportPng":{
1847
2017
  const k = draftKeyFrom(ctx, args);
1848
2018
  const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
1849
2019
  draft.data.modification_date = nowSec();
2020
+ // ST 兼容层(0.9.1):① 每个 PNG 都写两个块(ccv3 纯 CCv3 + chara 的 V2 投影,见 pngCardChunks);
2021
+ // ② stWorld 非空时,只在**导出副本**上写 `data.extensions.world`(ST 的角色↔世界书绑定)。
2022
+ // 返回给客户端的 draft 仍是「草稿本身」(多一个 ST 专有字段都不带),与「并入本卡」同一条家规。
2023
+ const exportCard = withStWorld(draft, args.stWorld);
1850
2024
  try{
1851
2025
  let pngBytes;
1852
2026
  if(args.imageB64 && typeof args.imageB64==="string" && args.imageB64.length>0){
1853
2027
  if(args.imageB64.length > 20*1024*1024) return fail("too-large","底图 PNG 过大(>20MB base64)");
1854
2028
  const baseBytes = b64DecodeBytes(args.imageB64);
1855
- pngBytes = embedCardIntoPngBytes(baseBytes, draft);
2029
+ pngBytes = embedCardIntoPngBytes(baseBytes, exportCard);
1856
2030
  } else {
1857
- pngBytes = buildPngWithCard(draft);
2031
+ pngBytes = buildPngWithCard(exportCard);
1858
2032
  }
1859
2033
  return ok({ b64: Buffer.from(pngBytes).toString("base64"), filename: (draft.data.name||"card")+".png", draft });
1860
2034
  }catch(e){ return fail("build-failed", String(e.message)); }
@@ -1863,7 +2037,7 @@ function apply(ctx, config){
1863
2037
  const b64 = args.b64;
1864
2038
  if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
1865
2039
  if(b64.length > 30*1024*1024) return fail("too-large","CHARX 过大(>30MB base64)");
1866
- let draft;
2040
+ let draft, upgradedFrom = null;
1867
2041
  try{
1868
2042
  const bytes = b64DecodeBytes(b64);
1869
2043
  if(bytes.length > 22*1024*1024) return fail("too-large","CHARX 过大(>22MB)");
@@ -1873,20 +2047,21 @@ function apply(ctx, config){
1873
2047
  if(!cardEntry) return fail("parse-failed","CHARX 未找到 card.json");
1874
2048
  const jsonStr = bytesToStr(cardEntry.data);
1875
2049
  const obj = JSON.parse(jsonStr);
1876
- draft = obj && obj.spec ? obj : (obj && obj.data ? obj : null);
1877
- if(!draft) throw new Error("card.json 格式错误");
1878
- draft = normalizeDraft(draft);
2050
+ upgradedFrom = upgradeCardToV3(obj).from;
2051
+ draft = normalizeDraft(obj);
1879
2052
  }catch(e){ return fail("parse-failed", String(e.message)); }
1880
2053
  const k = draftKeyFrom(ctx, args);
1881
2054
  setDraftFor(k, draft);
1882
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
2055
+ return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
1883
2056
  }
1884
2057
  case "cc_exportCharx":{
1885
2058
  const k = draftKeyFrom(ctx, args);
1886
2059
  const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
1887
2060
  draft.data.modification_date = nowSec();
2061
+ // ST 兼容层(0.9.1):只在**导出副本**上写世界书绑定,草稿/库里的卡不动(同「并入本卡」家规)
2062
+ const exportCard = withStWorld(draft, args.stWorld);
1888
2063
  try{
1889
- const zipBytes = buildZipCharx(draft);
2064
+ const zipBytes = buildZipCharx(exportCard);
1890
2065
  return ok({ b64: Buffer.from(zipBytes).toString("base64"), filename: (draft.data.name||"card")+".charx", draft });
1891
2066
  }catch(e){ return fail("build-failed", String(e.message)); }
1892
2067
  }
@@ -1954,6 +2129,14 @@ function apply(ctx, config){
1954
2129
  // 存储:<主根>/cc-books/<groupId>.json —— **一本书对一个分组**;文件本身就是可直接发布的
1955
2130
  // lorebook_v3 JSON(拷出去别人就能用),插件自己的状态放 `data.extensions.cc`(规范允许)。
1956
2131
  // 条目字段与卡内 `character_book.entries` **完全同构** —— 提升/回填都是零转换搬运。
2132
+ //
2133
+ // ⚠️ 0.9.1:这个文件是给**认 CCv3 的客户端**的,**SillyTavern 不认** —— ST 服务端
2134
+ // `/api/worldinfo/import` 拿到文件第一件事就是 `if (!('entries' in worldContent)) throw`
2135
+ // (用户实机:拖 `.lorebook.json` 进去 = `Error importing world info: … Bad Request`;
2136
+ // 1.19.0 的客户端 `importWorldInfo()` 也只转 Novel / Agnai / Risu,整份 `world-info.js`
2137
+ // 搜不到 `lorebook_v3`)。所以工坊导出页另给一枚「SillyTavern 世界书」:ST 原生形态
2138
+ // `{entries:{…}}`,条目字段按 ST 自己的 `convertCharacterBook()` 映射 —— 两枚按钮各有读者,
2139
+ // **别把其中一个说成「就是给客户端的通用格式」**(这正是 0.9.0 那轮写错边界的同一个毛病)。
1957
2140
  // ——————————————————————————————————————————————————————————————
1958
2141
  const BOOK_SPEC = "lorebook_v3";
1959
2142
  const BOOK_MAX_ENTRIES = 200;
@@ -2072,7 +2255,24 @@ async function bookCardsFor(ids){
2072
2255
  * 跨成员卡找「同一份设定」(纯函数):按内容签名分组,出现在 **≥2 张卡**里的才算候选。
2073
2256
  * 只报候选、不下结论 —— 同内容不同名、同名不同内容都如实标注,交给用户/模型拍板。
2074
2257
  */
2075
- function scanSharedEntries(cards){
2258
+ /**
2259
+ * 新建共享书的默认名:`<组名>·共享设定`(拿不到组名才退回「共享设定」)。
2260
+ * 第二轮报告 4.2:同一个人的多个组各建一本、全叫「共享设定」,导出成文件后根本分不清是哪一组。
2261
+ * 两半同构(`tests/shared-book.test.mjs` 直接比对),改这里记得同步 `lib/agent.js`。
2262
+ */
2263
+ function defaultBookName(groupName){
2264
+ const n = String(groupName == null ? "" : groupName).trim();
2265
+ return n ? n + "·共享设定" : "共享设定";
2266
+ }
2267
+ /** 新建一本空共享书(名字优先用调用方给的,否则 `<组名>·共享设定`)。两半同构。 */
2268
+ async function newBookFor(gid, explicitName){
2269
+ const want = String(explicitName == null ? "" : explicitName).trim();
2270
+ if(want) return normalizeBook({ data: { name: want } }, gid);
2271
+ let gname = "";
2272
+ try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
2273
+ return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
2274
+ }
2275
+ function scanSharedEntries(cards, bookEntries){
2076
2276
  const list = Array.isArray(cards) ? cards : [];
2077
2277
  const seen = new Map();
2078
2278
  list.forEach(c => {
@@ -2089,13 +2289,29 @@ function scanSharedEntries(cards){
2089
2289
  const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
2090
2290
  .sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
2091
2291
  .map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
2292
+ // 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
2293
+ // 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
2294
+ // 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
2295
+ const bookDupes = [];
2296
+ (Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
2297
+ const content = String((be && be.content) == null ? "" : be.content);
2298
+ const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
2299
+ if(!g || g.content !== content) return;
2300
+ bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
2301
+ content, keys: (be.keys || []).map(String), chars: content.length,
2302
+ cards: g.cards.slice(), cardNames: g.cardNames.slice(),
2303
+ preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
2304
+ });
2092
2305
  const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
2093
2306
  return {
2094
- candidates,
2307
+ candidates, bookDupes,
2095
2308
  stats: {
2096
2309
  cards: list.length, totalEntries,
2097
2310
  duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
2098
2311
  savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
2312
+ bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
2313
+ bookDupeCount: bookDupes.length,
2314
+ bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0),
2099
2315
  },
2100
2316
  };
2101
2317
  }
@@ -2122,19 +2338,22 @@ function applyBookPromotion(cards, bookEntries, signatures){
2122
2338
  const promoted = [];
2123
2339
  const nextCards = (Array.isArray(cards) ? cards : []).map(c => {
2124
2340
  const kept = [];
2125
- let removed = 0;
2341
+ let removed = 0, removedChars = 0;
2126
2342
  (Array.isArray(c.entries) ? c.entries : []).forEach(e => {
2127
2343
  const sig = contentKeyOf(e);
2128
2344
  if(want.has(sig)){
2129
2345
  if(!have.has(sig)){ have.add(sig); book.push(makeBookEntry(e)); promoted.push(sig); }
2130
2346
  removed += 1;
2347
+ removedChars += String(e.content == null ? "" : e.content).length;
2131
2348
  return;
2132
2349
  }
2133
2350
  kept.push(makeBookEntry(e));
2134
2351
  });
2135
- return { id: c.id, name: c.name, entries: kept, removed };
2352
+ return { id: c.id, name: c.name, entries: kept, removed, removedChars };
2136
2353
  });
2137
- return { bookEntries: book, cards: nextCards, promoted, removedTotal: nextCards.reduce((n, c) => n + c.removed, 0) };
2354
+ return { bookEntries: book, cards: nextCards, promoted,
2355
+ removedTotal: nextCards.reduce((n, c) => n + c.removed, 0),
2356
+ savedChars: nextCards.reduce((n, c) => n + c.removedChars, 0) };
2138
2357
  }
2139
2358
 
2140
2359
  export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclaredPresets, presetTargetRoot, presetSourceRoot,
@@ -2154,6 +2373,10 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
2154
2373
  // 0.9.0 组级共享世界书:落盘 + 纯函数(扫描/提升/回填),两半同构、条目与卡内 character_book 同形
2155
2374
  BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
2156
2375
  contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
2376
+ // 0.9.1 SillyTavern 兼容层:PNG 双块(chara 的 V2 投影 + 纯 CCv3)、ccv3 优先读取、ST 世界书绑定
2377
+ stV2Projection, stripDecorators, withStWorld, pngCardChunks, buildTEXtChunk, buildPngWithCard, embedCardIntoPngBytes, extractCardFromPngBytes,
2378
+ // 0.9.1 B2:V2/V1 卡升级成 CCv3(PNG/CHARX/JSON 三条导入路径共用这一份实现)
2379
+ upgradeCardToV3, normTags,
2157
2380
  REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
2158
2381
 
2159
2382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-cc-studio",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -38,7 +38,7 @@
38
38
  "presets"
39
39
  ],
40
40
  "scripts": {
41
- "test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/data-root.test.mjs",
41
+ "test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/shared-book-st-export.test.mjs && node tests/st-compat.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/data-root.test.mjs",
42
42
  "prepublishOnly": "npm test"
43
43
  },
44
44
  "dsh": {