@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/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
|
|
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", "
|
|
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,
|
|
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
|
-
|
|
740
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
791
|
-
|
|
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
|
|
802
|
-
|
|
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
|
-
|
|
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
|
|
931
|
+
const kwBytes=strToBytes(keyword||"ccv3");
|
|
809
932
|
const textBytes=strToBytes(b64);
|
|
810
|
-
const chunkData=concatBytes([
|
|
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,
|
|
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
|
|
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(
|
|
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 + ")—— 先重新读取再改", {
|
|
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
|
-
|
|
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
|
|
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) :
|
|
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
|
-
{
|
|
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) :
|
|
1914
|
+
const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid); // 默认名 `<组名>·共享设定`(报告 4.2)
|
|
1775
1915
|
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
1776
|
-
|
|
1777
|
-
const
|
|
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)
|
|
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,
|
|
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:
|
|
1786
|
-
removedTotal: plan.removedTotal,
|
|
1787
|
-
|
|
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
|
-
|
|
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,
|
|
2029
|
+
pngBytes = embedCardIntoPngBytes(baseBytes, exportCard);
|
|
1856
2030
|
} else {
|
|
1857
|
-
pngBytes = buildPngWithCard(
|
|
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
|
-
|
|
1877
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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": {
|