@xia-sc/dsh-cc-studio 0.8.2 → 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/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
- set({lastMsg: v.dryRun
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){ await refreshLibrary(); await loadGroup(gid); }
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
- try{ if(typeof window!=="undefined" && window.confirm && !window.confirm(t("store.confirmDelete"))) return; }catch(e){}
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")),
@@ -1997,8 +2050,11 @@ window.__ModuleLoader__.load({
1997
2050
  return h("div",{style:{border:"1px solid var(--dsw-alias-border-l1)", borderRadius:10, padding:"10px 12px", margin:"-4px 0 12px", background:"var(--dsw-alias-bg-base)"}},
1998
2051
  h("div",{style:{display:"flex", alignItems:"center", gap:8, flexWrap:"wrap"}},
1999
2052
  h("strong",{style:{fontSize:12}}, t("export.book.title")),
2000
- h("span",{style:S.badge}, bk.book.name||"—"),
2001
- h("span",{style:S.badge}, t("book.entries",{n:String(bk.book.entries.length)})),
2053
+ // ⚠️ 端点给的是**原始文档**(book.data.*)。这里曾经写成 `bk.book.entries` —— 于是
2054
+ // 任何「当前组有共享书」的卡一进导出页就抛 TypeError,整个工坊闪退、连 dsh 一起卡死
2055
+ // (0.8.2 实机事故;同一形状错误在关系视图与导航徽章上各犯过一次,别再读顶层)。
2056
+ h("span",{style:S.badge}, bk.book.data.name||"—"),
2057
+ h("span",{style:S.badge}, t("book.entries",{n:String(bk.book.data.entries.length)})),
2002
2058
  h("span",{style:S.badge}, t("book.tokens",{n:String(bk.tokens||0)}))),
2003
2059
  h("div",{style:{fontSize:11, color:"var(--dsw-alias-label-secondary)", margin:"6px 0 8px", lineHeight:1.7}}, t("export.book.hint")),
2004
2060
  h("div",{style:{display:"flex", gap:8, flexWrap:"wrap", alignItems:"center"}},
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
  /** 读分组:主根优先,回退旧根(与角色库同规)。 */
@@ -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 + ")—— 先重新读取再改", { staleRev: true, expectedRev: args.expectedRev, actualRev: 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
- return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name }, checkGroup(g, memberIds, cards)));
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 scan = scanSharedEntries(cards);
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) : normalizeBook({ data: { name: String(args.name || "共享设定") } }, 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
- { staleRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur) });
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) : normalizeBook({}, gid);
1787
+ const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid); // 默认名 `<组名>·共享设定`(报告 4.2)
1775
1788
  const cards = await bookCardsFor(await groupMemberIds(gid));
1776
- const scan = scanSharedEntries(cards);
1777
- const known = new Map(scan.candidates.map(c => [c.signature, c]));
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) : scan.candidates.map(c => c.signature);
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, chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch, keysMismatch: g.keysMismatch, preview: g.preview });
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: 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) });
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
- return ok({ dryRun: false, changed: true, promoted: plan.promoted.length, removedTotal: plan.removedTotal,
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
- function scanSharedEntries(cards){
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, removedTotal: nextCards.reduce((n, c) => n + c.removed, 0) };
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.8.2",
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": {