@xia-sc/dsh-cc-studio 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/lib/agent.js +512 -107
- package/lib/client.js +57 -4
- package/lib/index.js +84 -26
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -79,6 +79,7 @@ window.__ModuleLoader__.load({
|
|
|
79
79
|
"book.promoteHint":"把这 N 条搬进共享书,并从每张卡里删掉 —— 导出前先做这一步,卡里就不再有重复。",
|
|
80
80
|
"book.promotePreview":"将提升 {n} 条、从各卡共删 {m} 条重复",
|
|
81
81
|
"book.promoteDone":"已提升 {n} 条(删掉 {m} 条重复)",
|
|
82
|
+
"book.promoteSyncedDraft":"已同步当前草稿,删掉 {n} 条重复(否则保存时会把它们写回去)",
|
|
82
83
|
"book.exportBook":"导出共享世界书 JSON",
|
|
83
84
|
"book.exported":"已导出 {name}",
|
|
84
85
|
"book.applyCard":"合并进当前卡",
|
|
@@ -311,6 +312,8 @@ window.__ModuleLoader__.load({
|
|
|
311
312
|
"store.loadedCard":"已载入:{name} · {n} lore(ID:{id},再次保存将更新此卡)",
|
|
312
313
|
"store.errLoadFailed":"载入失败",
|
|
313
314
|
"store.confirmDelete":"确定删除该角色?此操作不可撤销。",
|
|
315
|
+
"relations.confirmDeleteGroup":"确定删除这个分组?分组里的节点与关系、以及该组的共享世界书(若有)会一起删掉,且不可恢复。成员卡不会被删除,只会离开这个组。",
|
|
316
|
+
"book.dupBook":"另有 {n} 条在成员卡里与共享书逐字重复(回填进卡的副本)—— 提升时会一并清掉卡里那几份,不会再往书里加。",
|
|
314
317
|
"store.deleted":"已删除",
|
|
315
318
|
"store.errDeleteFailed":"删除失败",
|
|
316
319
|
"store.needLoadFirst":"请先载入一张卡后再重命名",
|
|
@@ -390,6 +393,7 @@ window.__ModuleLoader__.load({
|
|
|
390
393
|
"book.promoteHint":"Move these N entries into the shared book and delete them from every card — do this before exporting and the cards hold no duplicates.",
|
|
391
394
|
"book.promotePreview":"Will promote {n} and delete {m} duplicates across the cards",
|
|
392
395
|
"book.promoteDone":"Promoted {n} (removed {m} duplicates)",
|
|
396
|
+
"book.promoteSyncedDraft":"Current draft synced — removed {n} duplicates (saving would have written them back)",
|
|
393
397
|
"book.exportBook":"Export shared lorebook JSON",
|
|
394
398
|
"book.exported":"Exported {name}",
|
|
395
399
|
"book.applyCard":"Merge into the current card",
|
|
@@ -621,6 +625,8 @@ window.__ModuleLoader__.load({
|
|
|
621
625
|
"store.loadedCard":"Loaded: {name} · {n} lore (ID:{id}, saving again will update this card)",
|
|
622
626
|
"store.errLoadFailed":"Load failed",
|
|
623
627
|
"store.confirmDelete":"Delete this character? This cannot be undone.",
|
|
628
|
+
"relations.confirmDeleteGroup":"Delete this group? Its nodes, relations and the group's shared lorebook (if any) go with it, and this cannot be undone. Member cards are not deleted — they just leave the group.",
|
|
629
|
+
"book.dupBook":"{n} more entries in the member cards are byte-identical to the shared book (copies merged into the cards) — promoting also cleans those up, without adding anything to the book.",
|
|
624
630
|
"store.deleted":"Deleted",
|
|
625
631
|
"store.errDeleteFailed":"Delete failed",
|
|
626
632
|
"store.needLoadFirst":"Load a card before renaming",
|
|
@@ -1090,6 +1096,40 @@ window.__ModuleLoader__.load({
|
|
|
1090
1096
|
}catch(e){ set({lastMsg:String(e && e.message || e)}); }
|
|
1091
1097
|
set({bookBusy:null});
|
|
1092
1098
|
}
|
|
1099
|
+
/**
|
|
1100
|
+
* 提升之后同步**界面里的草稿**(与工具半 syncDraftAfterPromote 同一套判据)。
|
|
1101
|
+
*
|
|
1102
|
+
* 宿主半 `cc_promoteBook` 删的是**库里的卡**,界面里这份草稿是另一个副本 —— 不同步的话,
|
|
1103
|
+
* 用户接着点「保存当前」就会把刚删掉的重复条目原样写回去,去重白做且毫无提示。
|
|
1104
|
+
* 三条自律:① 只对「这次真的被删过条目、且正是当前编辑的那张卡」动手;
|
|
1105
|
+
* ② 只认**内容逐字相符**(用户在界面里改过内容的条目一律不动);
|
|
1106
|
+
* ③ 共享书没读到(state.book 为空)时什么都不做 —— 宁可不同步,也不猜着删。
|
|
1107
|
+
*/
|
|
1108
|
+
function syncDraftAfterPromote(v){
|
|
1109
|
+
try{
|
|
1110
|
+
var cid = String(state.currentLibraryId||"");
|
|
1111
|
+
if(!cid) return 0;
|
|
1112
|
+
var touched = ((v && Array.isArray(v.perCard)) ? v.perCard : []).filter(function(c){
|
|
1113
|
+
return c && !c.error && c.removed > 0 && String(c.id) === cid;
|
|
1114
|
+
});
|
|
1115
|
+
if(!touched.length) return 0;
|
|
1116
|
+
var bk = state.book;
|
|
1117
|
+
var bookEntries = (bk && bk.book && bk.book.data && Array.isArray(bk.book.data.entries)) ? bk.book.data.entries : [];
|
|
1118
|
+
var contents = {};
|
|
1119
|
+
bookEntries.forEach(function(e){ var c = String((e && e.content)==null? "" : e.content); if(c.trim()) contents[c] = true; });
|
|
1120
|
+
var d = state.draft;
|
|
1121
|
+
var entries = (d && d.data && d.data.character_book && Array.isArray(d.data.character_book.entries)) ? d.data.character_book.entries : [];
|
|
1122
|
+
var removed = 0;
|
|
1123
|
+
entries.forEach(function(e){ if(contents[String((e && e.content)==null? "": e.content)]) removed++; });
|
|
1124
|
+
if(!removed) return 0;
|
|
1125
|
+
updateDraft(function(data){
|
|
1126
|
+
var cb = data.character_book;
|
|
1127
|
+
if(!cb || !Array.isArray(cb.entries)) return;
|
|
1128
|
+
cb.entries = cb.entries.filter(function(e){ return !contents[String((e && e.content)==null? "": e.content)]; });
|
|
1129
|
+
});
|
|
1130
|
+
return removed;
|
|
1131
|
+
}catch(e){ return 0; }
|
|
1132
|
+
}
|
|
1093
1133
|
/** 提升去重:`apply` 为假时只扫描预览(宿主半默认也是 dryRun,两边都不写盘)。 */
|
|
1094
1134
|
async function promoteBook(keys, apply){
|
|
1095
1135
|
var gid = String(state.currentGroupId||"");
|
|
@@ -1099,11 +1139,16 @@ window.__ModuleLoader__.load({
|
|
|
1099
1139
|
var res = await rpc("cc_promoteBook", {groupId: gid, apply: !!apply, keys: (keys && keys.length)? keys : undefined});
|
|
1100
1140
|
if(res && res.ok && res.value){
|
|
1101
1141
|
var v = res.value;
|
|
1102
|
-
|
|
1142
|
+
var msg = v.dryRun
|
|
1103
1143
|
? t("book.promotePreview",{n:String(v.wouldPromote||0), m:String(v.removedTotal||0)})
|
|
1104
|
-
: t("book.promoteDone",{n:String(v.promoted||0), m:String(v.removedTotal||0)})
|
|
1144
|
+
: t("book.promoteDone",{n:String(v.promoted||0), m:String(v.removedTotal||0)});
|
|
1145
|
+
set({lastMsg: msg});
|
|
1105
1146
|
await loadBook(gid);
|
|
1106
|
-
if(!v.dryRun){
|
|
1147
|
+
if(!v.dryRun){
|
|
1148
|
+
var synced = syncDraftAfterPromote(v); // 必须在 loadBook 之后:那时 state.book 才是提升后的新书
|
|
1149
|
+
if(synced) set({lastMsg: msg + " · " + t("book.promoteSyncedDraft",{n:String(synced)})});
|
|
1150
|
+
await refreshLibrary(); await loadGroup(gid);
|
|
1151
|
+
}
|
|
1107
1152
|
} else set({lastMsg:String((res && res.error && res.error.message) || "")});
|
|
1108
1153
|
}catch(e){ set({lastMsg:String(e && e.message || e)}); }
|
|
1109
1154
|
set({bookBusy:null});
|
|
@@ -1156,7 +1201,9 @@ window.__ModuleLoader__.load({
|
|
|
1156
1201
|
set({relBusy:null});
|
|
1157
1202
|
}
|
|
1158
1203
|
async function deleteGroup(id){
|
|
1159
|
-
|
|
1204
|
+
// 删组的确认文案要**专用**(原来复用了「确定删除该角色?」)—— 而且必须提共享世界书:
|
|
1205
|
+
// 那是用户可能整理了很久的东西,删掉不可恢复(第二轮报告 4.1 的同一条信息完整性要求)。
|
|
1206
|
+
try{ if(typeof window!=="undefined" && window.confirm && !window.confirm(t("relations.confirmDeleteGroup"))) return; }catch(e){}
|
|
1160
1207
|
set({relBusy:"del"});
|
|
1161
1208
|
try{
|
|
1162
1209
|
var res = await rpc("cc_deleteGroup", {id: id});
|
|
@@ -1837,6 +1884,9 @@ window.__ModuleLoader__.load({
|
|
|
1837
1884
|
var gidB = String(s.currentGroupId||"");
|
|
1838
1885
|
var bookReady = !!(bk && bk.exists && bk.book);
|
|
1839
1886
|
var dup = (bk && bk.scan && bk.scan.candidates) ? bk.scan.candidates : [];
|
|
1887
|
+
// 第二类重复:卡里的条目与共享书**逐字相同**(`cc_apply_shared_book` 回填进卡的副本)。
|
|
1888
|
+
// 界面从前也看不见它 —— 卡片间没重复、书里也没重复,于是那几份副本静静躺着准备双重插入。
|
|
1889
|
+
var dupeBook = (bk && bk.scan && bk.scan.bookDupes) ? bk.scan.bookDupes : [];
|
|
1840
1890
|
main=h("div",null,
|
|
1841
1891
|
h("div",{style:S.readCard},
|
|
1842
1892
|
h("h3",{style:S.readTitle}, t("book.title"),
|
|
@@ -1915,6 +1965,9 @@ window.__ModuleLoader__.load({
|
|
|
1915
1965
|
h("button",{type:"button", style:Object.assign({}, S.btn, {padding:"4px 12px", fontSize:12}), onMouseDown:stopFocusSteal,
|
|
1916
1966
|
disabled: !!(s.bookBusy==="scan"||s.bookBusy==="promote"),
|
|
1917
1967
|
onClick:function(){ store.promoteBook(null, true); }}, (s.bookBusy==="promote")? "…" : t("book.promote")))))),
|
|
1968
|
+
dupeBook.length
|
|
1969
|
+
? h("div",{style:{fontSize:11, color:"#ffc46b", marginTop:8, lineHeight:1.7}}, t("book.dupBook", {n:String(dupeBook.length)}))
|
|
1970
|
+
: null,
|
|
1918
1971
|
s.lastMsg? h("div",{style:{marginTop:10, fontSize:12, color:"var(--dsw-alias-label-secondary)", lineHeight:1.7}}, s.lastMsg):null),
|
|
1919
1972
|
(gidB && bookReady) ? h("div",{style:S.readCard},
|
|
1920
1973
|
h("h3",{style:S.readTitle}, t("export.book.title")),
|
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
|
/** 读分组:主根优先,回退旧根(与角色库同规)。 */
|
|
@@ -1661,7 +1670,7 @@ function apply(ctx, config){
|
|
|
1661
1670
|
if(!found) return fail("not-found", "分组不存在: " + String(args.id));
|
|
1662
1671
|
const cur = normalizeGroup(found.obj);
|
|
1663
1672
|
if(typeof args.expectedRev === "number" && args.expectedRev !== cur.rev){
|
|
1664
|
-
return fail("stale-rev", "分组已被外部修改(期望 rev=" + args.expectedRev + ",实际 rev=" + cur.rev + ")—— 先重新读取再改", {
|
|
1673
|
+
return fail("stale-rev", "分组已被外部修改(期望 rev=" + args.expectedRev + ",实际 rev=" + cur.rev + ")—— 先重新读取再改", { staleGroupRev: true, expectedRev: args.expectedRev, actualRev: cur.rev });
|
|
1665
1674
|
}
|
|
1666
1675
|
const applied = applyGroupOps(cur, args.ops);
|
|
1667
1676
|
if(!applied.applied.length) return ok({ group: cur, applied: [], rejected: applied.rejected, changed: false });
|
|
@@ -1674,7 +1683,10 @@ function apply(ctx, config){
|
|
|
1674
1683
|
const g = normalizeGroup(found.obj);
|
|
1675
1684
|
const memberIds = await groupMemberIds(g.id);
|
|
1676
1685
|
const cards = await libraryCardsFor(memberIds);
|
|
1677
|
-
|
|
1686
|
+
// 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)));
|
|
1678
1690
|
}
|
|
1679
1691
|
case "cc_renderRelations":{
|
|
1680
1692
|
// 渲染进**当前草稿**(与工具半共用同一份草稿文件);要落库仍需再存一次
|
|
@@ -1710,10 +1722,11 @@ function apply(ctx, config){
|
|
|
1710
1722
|
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1711
1723
|
const found = await readBookRaw(gid);
|
|
1712
1724
|
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
1713
|
-
const
|
|
1725
|
+
const book = found ? normalizeBook(found.obj, gid) : null;
|
|
1726
|
+
// 把书一起交给 scan → 认得出「卡内条目与共享书逐字重复」那一类(报告 P1)
|
|
1727
|
+
const scan = scanSharedEntries(cards, book ? book.data.entries : []);
|
|
1714
1728
|
const cardList = cards.map(c => ({ id: c.id, name: c.name, entries: (c.entries || []).length }));
|
|
1715
1729
|
if(!found) return ok({ exists: false, groupId: gid, cards: cardList, scan });
|
|
1716
|
-
const book = normalizeBook(found.obj, gid);
|
|
1717
1730
|
return ok({ exists: true, groupId: gid, book, rev: bookRevOf(book),
|
|
1718
1731
|
tokens: estimateTokensLite(book.data.entries.map(e => e.content).join("\n")),
|
|
1719
1732
|
cards: cardList, scan });
|
|
@@ -1722,10 +1735,10 @@ function apply(ctx, config){
|
|
|
1722
1735
|
const gid = safeGroupId(args.groupId);
|
|
1723
1736
|
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1724
1737
|
const found = await readBookRaw(gid);
|
|
1725
|
-
const cur = found ? normalizeBook(found.obj, gid) :
|
|
1738
|
+
const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid, args.name);
|
|
1726
1739
|
if(typeof args.expectedBookRev === "number" && args.expectedBookRev !== bookRevOf(cur)){
|
|
1727
1740
|
return fail("stale-rev", "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先重新读取再改",
|
|
1728
|
-
{
|
|
1741
|
+
{ staleBookRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur) });
|
|
1729
1742
|
}
|
|
1730
1743
|
const applied = [], rejected = [];
|
|
1731
1744
|
if(args.name !== undefined) cur.data.name = String(args.name).slice(0, 80);
|
|
@@ -1771,20 +1784,27 @@ function apply(ctx, config){
|
|
|
1771
1784
|
const gid = safeGroupId(args.groupId);
|
|
1772
1785
|
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1773
1786
|
const found = await readBookRaw(gid);
|
|
1774
|
-
const cur = found ? normalizeBook(found.obj, gid) :
|
|
1787
|
+
const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid); // 默认名 `<组名>·共享设定`(报告 4.2)
|
|
1775
1788
|
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
1776
|
-
|
|
1777
|
-
const
|
|
1789
|
+
// 候选两类:卡间重复(提升进书)+ 卡内条目与共享书逐字重复(书里已有,只清卡内副本)—— 见 agent 半同一处注释
|
|
1790
|
+
const scan = scanSharedEntries(cards, cur.data.entries);
|
|
1791
|
+
const known = new Map(scan.candidates.concat(scan.bookDupes).map(c => [c.signature, c]));
|
|
1778
1792
|
// 只认得扫描出来的候选:传了不存在的签名一律拒绝 —— 这是会**从多张卡里删条目**的操作,绝不猜
|
|
1779
|
-
const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String)
|
|
1793
|
+
const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String)
|
|
1794
|
+
: scan.candidates.map(c => c.signature).concat(scan.bookDupes.map(c => c.signature));
|
|
1780
1795
|
const unknownKeys = requested.filter(s => !known.has(s));
|
|
1781
1796
|
const use = requested.filter(s => known.has(s));
|
|
1797
|
+
const bookSigs = new Set(cur.data.entries.map(contentKeyOf));
|
|
1798
|
+
const promoteSigs = use.filter(s => !bookSigs.has(s));
|
|
1799
|
+
const cleanSigs = use.filter(s => bookSigs.has(s));
|
|
1782
1800
|
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,
|
|
1801
|
+
const summary = (g) => ({ kind: g.kind || "card-vs-card", signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames,
|
|
1802
|
+
chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch === true, keysMismatch: g.keysMismatch === true, preview: g.preview });
|
|
1784
1803
|
if(args.apply !== true){
|
|
1785
|
-
return ok({ dryRun: true, changed: false, wouldPromote:
|
|
1786
|
-
removedTotal: plan.removedTotal,
|
|
1787
|
-
|
|
1804
|
+
return ok({ dryRun: true, changed: false, wouldPromote: promoteSigs.length, wouldClean: cleanSigs.length, unknownKeys,
|
|
1805
|
+
removedTotal: plan.removedTotal, savedChars: plan.savedChars,
|
|
1806
|
+
perCard: plan.cards.map(c => ({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length })),
|
|
1807
|
+
candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats, rev: bookRevOf(cur) });
|
|
1788
1808
|
}
|
|
1789
1809
|
const bookNext = Object.assign({}, cur, { data: Object.assign({}, cur.data, { entries: plan.bookEntries }) });
|
|
1790
1810
|
const saved = await writeBook(gid, bookNext);
|
|
@@ -1800,9 +1820,11 @@ function apply(ctx, config){
|
|
|
1800
1820
|
touched.push({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length });
|
|
1801
1821
|
}catch(e){ touched.push({ id: c.id, name: c.name, error: String(e && e.message || e) }); }
|
|
1802
1822
|
}
|
|
1803
|
-
|
|
1823
|
+
const promotedCopies = cards.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.filter(e => promoteSigs.indexOf(contentKeyOf(e)) >= 0).length : 0), 0);
|
|
1824
|
+
const cleaned = Math.max(0, plan.removedTotal - promotedCopies);
|
|
1825
|
+
return ok({ dryRun: false, changed: true, promoted: plan.promoted.length, cleaned, removedTotal: plan.removedTotal,
|
|
1804
1826
|
unknownKeys, bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched,
|
|
1805
|
-
candidates: scan.candidates.map(summary), stats: scan.stats });
|
|
1827
|
+
candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats });
|
|
1806
1828
|
}
|
|
1807
1829
|
case "cc_applyBook":{
|
|
1808
1830
|
const gid = safeGroupId(args.groupId);
|
|
@@ -2072,7 +2094,24 @@ async function bookCardsFor(ids){
|
|
|
2072
2094
|
* 跨成员卡找「同一份设定」(纯函数):按内容签名分组,出现在 **≥2 张卡**里的才算候选。
|
|
2073
2095
|
* 只报候选、不下结论 —— 同内容不同名、同名不同内容都如实标注,交给用户/模型拍板。
|
|
2074
2096
|
*/
|
|
2075
|
-
|
|
2097
|
+
/**
|
|
2098
|
+
* 新建共享书的默认名:`<组名>·共享设定`(拿不到组名才退回「共享设定」)。
|
|
2099
|
+
* 第二轮报告 4.2:同一个人的多个组各建一本、全叫「共享设定」,导出成文件后根本分不清是哪一组。
|
|
2100
|
+
* 两半同构(`tests/shared-book.test.mjs` 直接比对),改这里记得同步 `lib/agent.js`。
|
|
2101
|
+
*/
|
|
2102
|
+
function defaultBookName(groupName){
|
|
2103
|
+
const n = String(groupName == null ? "" : groupName).trim();
|
|
2104
|
+
return n ? n + "·共享设定" : "共享设定";
|
|
2105
|
+
}
|
|
2106
|
+
/** 新建一本空共享书(名字优先用调用方给的,否则 `<组名>·共享设定`)。两半同构。 */
|
|
2107
|
+
async function newBookFor(gid, explicitName){
|
|
2108
|
+
const want = String(explicitName == null ? "" : explicitName).trim();
|
|
2109
|
+
if(want) return normalizeBook({ data: { name: want } }, gid);
|
|
2110
|
+
let gname = "";
|
|
2111
|
+
try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
|
|
2112
|
+
return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
|
|
2113
|
+
}
|
|
2114
|
+
function scanSharedEntries(cards, bookEntries){
|
|
2076
2115
|
const list = Array.isArray(cards) ? cards : [];
|
|
2077
2116
|
const seen = new Map();
|
|
2078
2117
|
list.forEach(c => {
|
|
@@ -2089,13 +2128,29 @@ function scanSharedEntries(cards){
|
|
|
2089
2128
|
const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
|
|
2090
2129
|
.sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
|
|
2091
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
|
+
});
|
|
2092
2144
|
const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
|
|
2093
2145
|
return {
|
|
2094
|
-
candidates,
|
|
2146
|
+
candidates, bookDupes,
|
|
2095
2147
|
stats: {
|
|
2096
2148
|
cards: list.length, totalEntries,
|
|
2097
2149
|
duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
|
|
2098
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),
|
|
2099
2154
|
},
|
|
2100
2155
|
};
|
|
2101
2156
|
}
|
|
@@ -2122,19 +2177,22 @@ function applyBookPromotion(cards, bookEntries, signatures){
|
|
|
2122
2177
|
const promoted = [];
|
|
2123
2178
|
const nextCards = (Array.isArray(cards) ? cards : []).map(c => {
|
|
2124
2179
|
const kept = [];
|
|
2125
|
-
let removed = 0;
|
|
2180
|
+
let removed = 0, removedChars = 0;
|
|
2126
2181
|
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2127
2182
|
const sig = contentKeyOf(e);
|
|
2128
2183
|
if(want.has(sig)){
|
|
2129
2184
|
if(!have.has(sig)){ have.add(sig); book.push(makeBookEntry(e)); promoted.push(sig); }
|
|
2130
2185
|
removed += 1;
|
|
2186
|
+
removedChars += String(e.content == null ? "" : e.content).length;
|
|
2131
2187
|
return;
|
|
2132
2188
|
}
|
|
2133
2189
|
kept.push(makeBookEntry(e));
|
|
2134
2190
|
});
|
|
2135
|
-
return { id: c.id, name: c.name, entries: kept, removed };
|
|
2191
|
+
return { id: c.id, name: c.name, entries: kept, removed, removedChars };
|
|
2136
2192
|
});
|
|
2137
|
-
return { bookEntries: book, cards: nextCards, promoted,
|
|
2193
|
+
return { bookEntries: book, cards: nextCards, promoted,
|
|
2194
|
+
removedTotal: nextCards.reduce((n, c) => n + c.removed, 0),
|
|
2195
|
+
savedChars: nextCards.reduce((n, c) => n + c.removedChars, 0) };
|
|
2138
2196
|
}
|
|
2139
2197
|
|
|
2140
2198
|
export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclaredPresets, presetTargetRoot, presetSourceRoot,
|
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.0",
|
|
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/promote-draft-sync.test.mjs && node tests/data-root.test.mjs",
|
|
42
42
|
"prepublishOnly": "npm test"
|
|
43
43
|
},
|
|
44
44
|
"dsh": {
|