@xia-sc/dsh-cc-studio 0.8.1 → 0.8.2
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 +28 -25
- package/lib/agent.js +757 -6
- package/lib/client.js +638 -129
- package/lib/index.js +423 -29
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -570,22 +570,33 @@ function applyGroupOps(group, ops){
|
|
|
570
570
|
* 生成【关系】段(纯函数)。默认只写出边(我 → 他)—— 卡片是以"我"为视角的;
|
|
571
571
|
* includeIncoming 时追加一节「他们眼中的我」(扮演时知道对方怎么看我,是剧情张力来源,默认关)。
|
|
572
572
|
*/
|
|
573
|
+
/**
|
|
574
|
+
* 出边口径(渲染与 outgoing 计数**共用这一处**,避免「预览说 N 条、实际写出 M 条」)。
|
|
575
|
+
* mutual(对等)边是双向的:不管它从哪一端写的,两端都该看到它 —— 所以「指向我的 mutual 边」
|
|
576
|
+
* 也算我的出边。⚠️ 曾经 outgoing 只认 `from === myNodeId`,而 incoming 又显式排除 mutual,
|
|
577
|
+
* 于是 mutual 边**两头都不落**、渲染时被静默丢弃(做的「对等关系」在导出的卡里彻底消失)。
|
|
578
|
+
*/
|
|
579
|
+
function outgoingEdgesOf(myNodeId, edges){
|
|
580
|
+
return (Array.isArray(edges) ? edges : []).filter(e => e.from === myNodeId || (e.direction === "mutual" && e.to === myNodeId));
|
|
581
|
+
}
|
|
573
582
|
function renderRelationsSection(myNodeId, nodes, edges, opts){
|
|
574
583
|
const o = opts || {};
|
|
575
584
|
const byId = new Map((Array.isArray(nodes) ? nodes : []).map(n => [n.id, n]));
|
|
576
585
|
const nameOf = (id) => { const n = byId.get(id); return n ? (n.name || "未命名") : "(未知)"; };
|
|
577
586
|
const all = (Array.isArray(edges) ? edges : []).filter(e => byId.has(e.from) && byId.has(e.to));
|
|
578
|
-
const outgoing =
|
|
587
|
+
const outgoing = outgoingEdgesOf(myNodeId, all);
|
|
588
|
+
// incoming 仍排除 mutual:同一条对等关系不必在两节各说一遍(出边那节已带「,互向」标记)
|
|
579
589
|
const incoming = all.filter(e => e.to === myNodeId && e.direction !== "mutual");
|
|
590
|
+
const otherOf = (e) => (e.from === myNodeId ? e.to : e.from); // mutual 边可能是反向写着的,对方在 from 端
|
|
580
591
|
const order = (t) => { const i = REL_TYPES.indexOf(t); return i < 0 ? REL_TYPES.length : i; };
|
|
581
592
|
const fmt = (e, otherId) => "- " + nameOf(otherId) + "(" + relLabel(e) + "|" + e.strength + "/5" + (e.direction === "mutual" ? ",互向" : "") + ")" + (e.note ? ":" + e.note : "");
|
|
582
|
-
const byTypeThenStrength = (other) => (a, b) => (order(a.type) - order(b.type)) || (b.strength - a.strength) || nameOf(a
|
|
593
|
+
const byTypeThenStrength = (other) => (a, b) => (order(a.type) - order(b.type)) || (b.strength - a.strength) || nameOf(other(a)).localeCompare(nameOf(other(b)));
|
|
583
594
|
const lines = ["【关系】"];
|
|
584
595
|
if(!outgoing.length) lines.push("- (暂无)");
|
|
585
|
-
else outgoing.slice().sort(byTypeThenStrength(
|
|
596
|
+
else outgoing.slice().sort(byTypeThenStrength(otherOf)).forEach(e => lines.push(fmt(e, otherOf(e))));
|
|
586
597
|
if(o.includeIncoming && incoming.length){
|
|
587
598
|
lines.push("", "【他们眼中的我】");
|
|
588
|
-
incoming.slice().sort(byTypeThenStrength(
|
|
599
|
+
incoming.slice().sort(byTypeThenStrength(e => e.from)).forEach(e => lines.push(fmt(e, e.from)));
|
|
589
600
|
}
|
|
590
601
|
return lines.join("\n");
|
|
591
602
|
}
|
|
@@ -999,6 +1010,52 @@ async function groupMemberIds(groupId){
|
|
|
999
1010
|
const entries = await listLibraryEntries();
|
|
1000
1011
|
return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
|
|
1001
1012
|
}
|
|
1013
|
+
/**
|
|
1014
|
+
* 按名字找组(建组/改名时查重):trim 后精确比对;带 exceptId 时排除自己(给自己改名不算撞车)。
|
|
1015
|
+
* 两半同构 —— 界面和模型必须落到同一条规则上,否则会出现「模型建不了同名组、界面却建了两个」。
|
|
1016
|
+
*/
|
|
1017
|
+
function findGroupByName(groups, name, exceptId){
|
|
1018
|
+
const want = String(name || "").trim();
|
|
1019
|
+
if(!want) return null;
|
|
1020
|
+
const eid = exceptId ? safeGroupId(exceptId) : "";
|
|
1021
|
+
return (Array.isArray(groups) ? groups : []).find(g => g.id !== eid && String(g.name || "").trim() === want) || null;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* 把一张已存卡放进分组(groupId 传空 = 移出)。与 lib/agent.js **逐字同构** —— 界面与模型必须落到
|
|
1025
|
+
* 同一套行为上(否则会出现「模型放进去能复活旧节点、界面放进去却新建一个同名节点」这种分叉)。
|
|
1026
|
+
* 入组时若组里还没有它的节点就自动补一个;卡曾因被删而降级的同名 orphan 节点会被**复活**(保住旧关系)。
|
|
1027
|
+
* 移出**不删节点、不删关系**:那是用户建过的资产,挪回来时关系还在。
|
|
1028
|
+
*/
|
|
1029
|
+
async function assignCardToGroup(cardId, groupId){
|
|
1030
|
+
const cid = String(cardId || "");
|
|
1031
|
+
if(!cid) throw new Error("需要 cardId");
|
|
1032
|
+
const entries = await listLibraryEntries();
|
|
1033
|
+
const mine = entries.find(e => e.id === cid);
|
|
1034
|
+
if(!mine) throw Object.assign(new Error("找不到角色卡: " + cid), { code: "ENOENT" });
|
|
1035
|
+
const gid = (groupId === null || groupId === undefined || groupId === "") ? "" : safeGroupId(groupId);
|
|
1036
|
+
if(!gid){
|
|
1037
|
+
await patchLibraryMeta(cid, { groupId: null });
|
|
1038
|
+
return { cardId: cid, cardName: mine.name, previousGroupId: (mine.groupId ? String(mine.groupId) : null), groupId: null, nodeId: null, revived: false };
|
|
1039
|
+
}
|
|
1040
|
+
const found = await readGroupRaw(gid);
|
|
1041
|
+
if(!found) throw Object.assign(new Error("分组不存在: " + gid), { code: "not-found" });
|
|
1042
|
+
const g = normalizeGroup(found.obj);
|
|
1043
|
+
let node = g.nodes.find(n => n.kind === "card" && n.cardId === cid);
|
|
1044
|
+
let revived = false;
|
|
1045
|
+
if(!node){
|
|
1046
|
+
node = g.nodes.find(n => n.kind === "card" && !n.cardId && n.orphanOf && n.orphanOf === String(mine.name || ""));
|
|
1047
|
+
if(node){ node.cardId = cid; node.orphanOf = null; revived = true; }
|
|
1048
|
+
}
|
|
1049
|
+
if(!node){
|
|
1050
|
+
node = { id: "n" + (g.nextNodeSeq++), kind: "card", cardId: cid, name: mine.name || "未命名角色", aliases: [], orphanOf: null };
|
|
1051
|
+
g.nodes.push(node);
|
|
1052
|
+
await writeGroup(g);
|
|
1053
|
+
} else if(revived){
|
|
1054
|
+
await writeGroup(g);
|
|
1055
|
+
}
|
|
1056
|
+
await patchLibraryMeta(cid, { groupId: gid });
|
|
1057
|
+
return { cardId: cid, cardName: mine.name, previousGroupId: (mine.groupId ? String(mine.groupId) : null), groupId: gid, nodeId: node.id, groupRev: g.rev, revived };
|
|
1058
|
+
}
|
|
1002
1059
|
/** 0.8.0:体检 / 渲染需要的卡片摘要(只读;卡不在库里就跳过)。 */
|
|
1003
1060
|
async function libraryCardsFor(ids){
|
|
1004
1061
|
const out = [];
|
|
@@ -1526,15 +1583,25 @@ function apply(ctx, config){
|
|
|
1526
1583
|
const groups = await listGroups();
|
|
1527
1584
|
const entries = await listLibraryEntries();
|
|
1528
1585
|
const countOf = (gid) => entries.filter(e => String(e.groupId || "") === gid).length;
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1586
|
+
const out = [];
|
|
1587
|
+
for(const g of groups){
|
|
1588
|
+
const b = await readBookRaw(g.id); // 组级共享世界书摘要(UI 要在分组标题上显示「共享 N 条」)
|
|
1589
|
+
out.push({
|
|
1590
|
+
id: g.id, name: g.name, note: g.note,
|
|
1591
|
+
nodeCount: g.nodes.length, edgeCount: g.edges.length,
|
|
1592
|
+
memberCount: countOf(g.id), rev: g.rev, updatedAt: g.updatedAt,
|
|
1593
|
+
hasBook: !!b, bookEntries: b ? normalizeBook(b.obj, g.id).data.entries.length : 0,
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
return ok({ groups: out });
|
|
1534
1597
|
}
|
|
1535
1598
|
case "cc_createGroup":{
|
|
1536
1599
|
const name = String(args.name || "").trim().slice(0, 60);
|
|
1537
1600
|
if(!name) return fail("invalid-name", "分组名不能为空");
|
|
1601
|
+
// 0.8.0:同名分组默认拒绝 —— 否则侧栏里会出现两个「测试」,人和模型都分不清该往哪个放卡
|
|
1602
|
+
// (工具半 cc_create_group 是同一套规则,那边另有 allowDuplicate 逃生门)
|
|
1603
|
+
const dup = findGroupByName(await listGroups(), name, "");
|
|
1604
|
+
if(dup && args.allowDuplicate !== true) return fail("duplicate-name", "已有同名分组「" + dup.name + "」(id " + dup.id + ")", { existingGroupId: dup.id });
|
|
1538
1605
|
const g = await writeGroup({ id: newGroupId(), name, note: String(args.note || "").slice(0, 200), nodes: [], edges: [] });
|
|
1539
1606
|
return ok({ group: g });
|
|
1540
1607
|
}
|
|
@@ -1542,9 +1609,17 @@ function apply(ctx, config){
|
|
|
1542
1609
|
const found = await readGroupRaw(args.id);
|
|
1543
1610
|
if(!found) return fail("not-found", "分组不存在: " + String(args.id));
|
|
1544
1611
|
const g = normalizeGroup(found.obj);
|
|
1545
|
-
|
|
1546
|
-
if(typeof args.
|
|
1547
|
-
|
|
1612
|
+
const changed = [];
|
|
1613
|
+
if(typeof args.name === "string" && args.name.trim()){
|
|
1614
|
+
const name = args.name.trim().slice(0, 60);
|
|
1615
|
+
const dup = findGroupByName(await listGroups(), name, g.id);
|
|
1616
|
+
if(dup && args.allowDuplicate !== true) return fail("duplicate-name", "已有同名分组「" + dup.name + "」(id " + dup.id + ")", { existingGroupId: dup.id });
|
|
1617
|
+
if(name !== g.name){ g.name = name; changed.push("name"); }
|
|
1618
|
+
}
|
|
1619
|
+
if(typeof args.note === "string" && args.note.slice(0, 200) !== g.note){ g.note = args.note.slice(0, 200); changed.push("note"); }
|
|
1620
|
+
// 无实际改动不写盘(同 N4 家规:空改动不该白涨 rev,否则别人手上的 rev 前提会被无变化打掉)
|
|
1621
|
+
if(!changed.length) return ok({ group: g, changed: [] });
|
|
1622
|
+
return ok({ group: await writeGroup(g), changed });
|
|
1548
1623
|
}
|
|
1549
1624
|
case "cc_deleteGroup":{
|
|
1550
1625
|
const removed = await deleteGroupFile(args.id);
|
|
@@ -1556,29 +1631,23 @@ function apply(ctx, config){
|
|
|
1556
1631
|
if(String(e.groupId || "") !== gid) continue;
|
|
1557
1632
|
try{ await patchLibraryMeta(e.id, { groupId: null }); memberCleared += 1; }catch{}
|
|
1558
1633
|
}
|
|
1559
|
-
|
|
1634
|
+
// 0.9.0:组级共享世界书随组一起删(它属于这个组,留下就是孤儿文件)
|
|
1635
|
+
const bookDeleted = await deleteBook(gid);
|
|
1636
|
+
return ok({ deleted: removed, memberCleared, bookDeleted });
|
|
1560
1637
|
}
|
|
1561
1638
|
case "cc_setCardGroup":{
|
|
1562
1639
|
const cardId = String(args.cardId || "");
|
|
1563
1640
|
if(!cardId) return fail("invalid-card", "需要 cardId");
|
|
1564
1641
|
const want = (args.groupId === null || args.groupId === undefined || args.groupId === "") ? null : safeGroupId(args.groupId);
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
if(
|
|
1572
|
-
|
|
1573
|
-
node = { id: "n" + (g.nextNodeSeq++), kind: "card", cardId, name: (card && card.name) || "未命名角色", aliases: [], orphanOf: null };
|
|
1574
|
-
g.nodes.push(node);
|
|
1575
|
-
await writeGroup(g);
|
|
1576
|
-
}
|
|
1577
|
-
await patchLibraryMeta(cardId, { groupId: want });
|
|
1578
|
-
return ok({ cardId, groupId: want, nodeId: node.id });
|
|
1642
|
+
// 走与工具半**同一份** assignCardToGroup:自动补节点、复活同名 orphan 节点、移出时保留节点与边
|
|
1643
|
+
try{
|
|
1644
|
+
const r = await assignCardToGroup(cardId, want);
|
|
1645
|
+
return ok({ cardId: r.cardId, groupId: r.groupId, nodeId: r.nodeId, previousGroupId: r.previousGroupId, revived: r.revived === true });
|
|
1646
|
+
}catch(e){
|
|
1647
|
+
if(e && e.code === "ENOENT") return fail("not-found", "库里找不到这张卡: " + cardId);
|
|
1648
|
+
if(e && e.code === "not-found") return fail("not-found", "分组不存在: " + want);
|
|
1649
|
+
throw e;
|
|
1579
1650
|
}
|
|
1580
|
-
await patchLibraryMeta(cardId, { groupId: null });
|
|
1581
|
-
return ok({ cardId, groupId: null });
|
|
1582
1651
|
}
|
|
1583
1652
|
case "cc_getGroup":{
|
|
1584
1653
|
const found = await readGroupRaw(args.id);
|
|
@@ -1634,6 +1703,131 @@ function apply(ctx, config){
|
|
|
1634
1703
|
}
|
|
1635
1704
|
return ok({ changed, groupId: g.id, rev: g.rev, description: after });
|
|
1636
1705
|
}
|
|
1706
|
+
// ———————————————— 组级共享世界书(0.9.0)————————————————
|
|
1707
|
+
// 一本书对一个分组,存 <主根>/cc-books/<groupId>.json(本身就是可发布的 lorebook_v3 JSON)。
|
|
1708
|
+
case "cc_getBook":{
|
|
1709
|
+
const gid = safeGroupId(args.groupId);
|
|
1710
|
+
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1711
|
+
const found = await readBookRaw(gid);
|
|
1712
|
+
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
1713
|
+
const scan = scanSharedEntries(cards);
|
|
1714
|
+
const cardList = cards.map(c => ({ id: c.id, name: c.name, entries: (c.entries || []).length }));
|
|
1715
|
+
if(!found) return ok({ exists: false, groupId: gid, cards: cardList, scan });
|
|
1716
|
+
const book = normalizeBook(found.obj, gid);
|
|
1717
|
+
return ok({ exists: true, groupId: gid, book, rev: bookRevOf(book),
|
|
1718
|
+
tokens: estimateTokensLite(book.data.entries.map(e => e.content).join("\n")),
|
|
1719
|
+
cards: cardList, scan });
|
|
1720
|
+
}
|
|
1721
|
+
case "cc_patchBook":{
|
|
1722
|
+
const gid = safeGroupId(args.groupId);
|
|
1723
|
+
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1724
|
+
const found = await readBookRaw(gid);
|
|
1725
|
+
const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({ data: { name: String(args.name || "共享设定") } }, gid);
|
|
1726
|
+
if(typeof args.expectedBookRev === "number" && args.expectedBookRev !== bookRevOf(cur)){
|
|
1727
|
+
return fail("stale-rev", "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先重新读取再改",
|
|
1728
|
+
{ staleRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur) });
|
|
1729
|
+
}
|
|
1730
|
+
const applied = [], rejected = [];
|
|
1731
|
+
if(args.name !== undefined) cur.data.name = String(args.name).slice(0, 80);
|
|
1732
|
+
if(args.description !== undefined) cur.data.description = String(args.description).slice(0, 300);
|
|
1733
|
+
if(args.scan_depth !== undefined) cur.data.scan_depth = Math.max(1, Math.round(Number(args.scan_depth) || 4));
|
|
1734
|
+
if(args.token_budget !== undefined) cur.data.token_budget = Math.max(100, Math.round(Number(args.token_budget) || 2000));
|
|
1735
|
+
if(args.recursive_scanning !== undefined) cur.data.recursive_scanning = args.recursive_scanning === true;
|
|
1736
|
+
["name", "description", "scan_depth", "token_budget", "recursive_scanning"].forEach(k => { if(args[k] !== undefined) applied.push({ op: "setMeta", field: k }); });
|
|
1737
|
+
(Array.isArray(args.ops) ? args.ops : []).forEach(op => {
|
|
1738
|
+
const kind = String((op && op.op) || "");
|
|
1739
|
+
if(kind === "addEntry"){
|
|
1740
|
+
const e = makeBookEntry(op.entry);
|
|
1741
|
+
if(!e.content.trim()){ rejected.push({ op, error: "content 不能为空" }); return; }
|
|
1742
|
+
if(cur.data.entries.some(x => contentKeyOf(x) === contentKeyOf(e))){ rejected.push({ op, error: "同内容的条目已存在" }); return; }
|
|
1743
|
+
if(cur.data.entries.length >= BOOK_MAX_ENTRIES){ rejected.push({ op, error: "条目数已达上限 " + BOOK_MAX_ENTRIES }); return; }
|
|
1744
|
+
cur.data.entries.push(e);
|
|
1745
|
+
applied.push({ op: kind, key: contentKeyOf(e), name: e.name });
|
|
1746
|
+
} else if(kind === "updateEntry"){
|
|
1747
|
+
const idx = cur.data.entries.findIndex(x => contentKeyOf(x) === String(op.contentKey || ""));
|
|
1748
|
+
if(idx < 0){ rejected.push({ op, error: "找不到条目(contentKey 不匹配)" }); return; }
|
|
1749
|
+
const patch = (op.patch && typeof op.patch === "object") ? op.patch : {};
|
|
1750
|
+
cur.data.entries[idx] = makeBookEntry(Object.assign({}, cur.data.entries[idx], patch));
|
|
1751
|
+
applied.push({ op: kind, key: contentKeyOf(cur.data.entries[idx]) });
|
|
1752
|
+
} else if(kind === "removeEntry"){
|
|
1753
|
+
// 支持两种定位:`index`(UI 列表直接用它)或 `contentKey`(模型读到的签名)
|
|
1754
|
+
const byIndex = Number.isInteger(op.index) ? op.index : -1;
|
|
1755
|
+
if(byIndex >= 0 && byIndex < cur.data.entries.length){
|
|
1756
|
+
const gone = cur.data.entries.splice(byIndex, 1)[0];
|
|
1757
|
+
applied.push({ op: kind, index: byIndex, key: contentKeyOf(gone) });
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
const before = cur.data.entries.length;
|
|
1761
|
+
cur.data.entries = cur.data.entries.filter(x => contentKeyOf(x) !== String(op.contentKey || ""));
|
|
1762
|
+
if(cur.data.entries.length === before){ rejected.push({ op, error: "找不到条目(index / contentKey 都不匹配)" }); return; }
|
|
1763
|
+
applied.push({ op: kind, key: String(op.contentKey) });
|
|
1764
|
+
} else rejected.push({ op, error: "未知 op:" + kind });
|
|
1765
|
+
});
|
|
1766
|
+
if(!applied.length) return ok({ book: cur, rev: bookRevOf(cur), applied: [], rejected, changed: false });
|
|
1767
|
+
const saved = await writeBook(gid, cur);
|
|
1768
|
+
return ok({ book: saved, rev: bookRevOf(saved), applied, rejected, changed: true });
|
|
1769
|
+
}
|
|
1770
|
+
case "cc_promoteBook":{
|
|
1771
|
+
const gid = safeGroupId(args.groupId);
|
|
1772
|
+
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1773
|
+
const found = await readBookRaw(gid);
|
|
1774
|
+
const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({}, gid);
|
|
1775
|
+
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
1776
|
+
const scan = scanSharedEntries(cards);
|
|
1777
|
+
const known = new Map(scan.candidates.map(c => [c.signature, c]));
|
|
1778
|
+
// 只认得扫描出来的候选:传了不存在的签名一律拒绝 —— 这是会**从多张卡里删条目**的操作,绝不猜
|
|
1779
|
+
const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String) : scan.candidates.map(c => c.signature);
|
|
1780
|
+
const unknownKeys = requested.filter(s => !known.has(s));
|
|
1781
|
+
const use = requested.filter(s => known.has(s));
|
|
1782
|
+
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 });
|
|
1784
|
+
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) });
|
|
1788
|
+
}
|
|
1789
|
+
const bookNext = Object.assign({}, cur, { data: Object.assign({}, cur.data, { entries: plan.bookEntries }) });
|
|
1790
|
+
const saved = await writeBook(gid, bookNext);
|
|
1791
|
+
const touched = [];
|
|
1792
|
+
for(const c of plan.cards){
|
|
1793
|
+
if(!c.removed) continue;
|
|
1794
|
+
try{
|
|
1795
|
+
const rawOne = await readLibraryRaw(c.id);
|
|
1796
|
+
const obj = JSON.parse(rawOne.raw);
|
|
1797
|
+
obj.draft.data.character_book = Object.assign({}, obj.draft.data.character_book, { entries: c.entries });
|
|
1798
|
+
if(obj.meta){ obj.meta.loreCount = c.entries.length; obj.meta.updatedAt = nowSec(); } // 只更新这两个字段,其余 meta 原样保留(B4 的教训:绝不重建 meta)
|
|
1799
|
+
await writeFile(rawOne.path, JSON.stringify(obj, null, 2), "utf8");
|
|
1800
|
+
touched.push({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length });
|
|
1801
|
+
}catch(e){ touched.push({ id: c.id, name: c.name, error: String(e && e.message || e) }); }
|
|
1802
|
+
}
|
|
1803
|
+
return ok({ dryRun: false, changed: true, promoted: plan.promoted.length, removedTotal: plan.removedTotal,
|
|
1804
|
+
unknownKeys, bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched,
|
|
1805
|
+
candidates: scan.candidates.map(summary), stats: scan.stats });
|
|
1806
|
+
}
|
|
1807
|
+
case "cc_applyBook":{
|
|
1808
|
+
const gid = safeGroupId(args.groupId);
|
|
1809
|
+
if(!gid) return fail("invalid-group", "需要 groupId");
|
|
1810
|
+
const found = await readBookRaw(gid);
|
|
1811
|
+
if(!found) return fail("not-found", "这个分组还没有共享世界书");
|
|
1812
|
+
const cur = normalizeBook(found.obj, gid);
|
|
1813
|
+
const k = draftKeyFrom(ctx, args);
|
|
1814
|
+
const origin = readDraftOrigin(k);
|
|
1815
|
+
const cardId = args.cardId ? String(args.cardId) : ((origin && origin.libraryId) || "");
|
|
1816
|
+
if(!cardId) return fail("no-card", "没有指定 cardId,当前草稿也没有来源卡(先保存到侧边栏)");
|
|
1817
|
+
const rawOne = await readLibraryRaw(cardId);
|
|
1818
|
+
const obj = JSON.parse(rawOne.raw);
|
|
1819
|
+
const cardEntries = (obj.draft && obj.draft.data && obj.draft.data.character_book && Array.isArray(obj.draft.data.character_book.entries))
|
|
1820
|
+
? obj.draft.data.character_book.entries : [];
|
|
1821
|
+
const merged = mergeBookEntries(cur.data.entries, cardEntries);
|
|
1822
|
+
if(args.dryRun === true){
|
|
1823
|
+
return ok({ dryRun: true, cardId, wouldAdd: merged.added.length, bookEntries: cur.data.entries.length, cardEntries: cardEntries.length });
|
|
1824
|
+
}
|
|
1825
|
+
if(!merged.added.length) return ok({ cardId, added: 0, changed: false, loreCount: cardEntries.length });
|
|
1826
|
+
obj.draft.data.character_book = Object.assign({}, obj.draft.data.character_book, { entries: merged.entries });
|
|
1827
|
+
if(obj.meta){ obj.meta.loreCount = merged.entries.length; obj.meta.updatedAt = nowSec(); }
|
|
1828
|
+
await writeFile(rawOne.path, JSON.stringify(obj, null, 2), "utf8");
|
|
1829
|
+
return ok({ cardId, added: merged.added.length, changed: true, loreCount: merged.entries.length });
|
|
1830
|
+
}
|
|
1637
1831
|
case "cc_importFromPng":{
|
|
1638
1832
|
const b64 = args.b64;
|
|
1639
1833
|
if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
|
|
@@ -1748,6 +1942,201 @@ function apply(ctx, config){
|
|
|
1748
1942
|
}), "dsh-cc-studio: POST "+RPC_CHANNEL+"/*");
|
|
1749
1943
|
}
|
|
1750
1944
|
|
|
1945
|
+
// ——————————————————————————————————————————————————————————————
|
|
1946
|
+
// 组级共享世界书(0.9.0):一份 JSON 整组共用,成员卡里只留角色私有条目
|
|
1947
|
+
//
|
|
1948
|
+
// 为什么:群聊卡的世界设定(年表/势力/地理/力量/日常)在每张成员卡里**逐字重复**。实测一组
|
|
1949
|
+
// 4 张卡共 20 条,其中 5 条是完全相同的同一份设定(约 4500 字符纯冗余),改一条要改 4 遍。
|
|
1950
|
+
// CCv3 规范本身给了两个依据:① Lorebook 可以**单独导出** —— `{ spec: "lorebook_v3", data: Lorebook }`;
|
|
1951
|
+
// ② `character_book` 是「角色专属」的那一本,规范明说它应当能与一个全局 lorebook **叠加**。
|
|
1952
|
+
// 所以把共享设定提到组级独立文件,客户端里绑成全局/世界书即可,成员卡不必再存。
|
|
1953
|
+
//
|
|
1954
|
+
// 存储:<主根>/cc-books/<groupId>.json —— **一本书对一个分组**;文件本身就是可直接发布的
|
|
1955
|
+
// lorebook_v3 JSON(拷出去别人就能用),插件自己的状态放 `data.extensions.cc`(规范允许)。
|
|
1956
|
+
// 条目字段与卡内 `character_book.entries` **完全同构** —— 提升/回填都是零转换搬运。
|
|
1957
|
+
// ——————————————————————————————————————————————————————————————
|
|
1958
|
+
const BOOK_SPEC = "lorebook_v3";
|
|
1959
|
+
const BOOK_MAX_ENTRIES = 200;
|
|
1960
|
+
|
|
1961
|
+
function bookDirs(){ return dataRoots("cc-books"); }
|
|
1962
|
+
function bookDir(){ return bookDirs()[0]; }
|
|
1963
|
+
function bookPath(groupId){ return join(bookDir(), safeGroupId(groupId) + ".json"); }
|
|
1964
|
+
async function ensureBookDir(){ try{ await mkdir(bookDir(), { recursive: true }); }catch{} }
|
|
1965
|
+
|
|
1966
|
+
/** 条目归一化:**只补缺、不裁剪** —— CCv3 要求应用把不认识的字段也原样保存(否则导入的书会被削掉)。 */
|
|
1967
|
+
function makeBookEntry(e){
|
|
1968
|
+
const src = (e && typeof e === "object") ? e : {};
|
|
1969
|
+
const out = Object.assign({}, src);
|
|
1970
|
+
out.keys = Array.isArray(src.keys) ? src.keys.map(String) : [];
|
|
1971
|
+
out.content = String(src.content == null ? "" : src.content);
|
|
1972
|
+
out.enabled = src.enabled !== false;
|
|
1973
|
+
out.insertion_order = Number.isFinite(Number(src.insertion_order)) ? Number(src.insertion_order) : 0;
|
|
1974
|
+
out.constant = src.constant === true;
|
|
1975
|
+
out.use_regex = src.use_regex === true;
|
|
1976
|
+
if(typeof out.name !== "string") out.name = out.name == null ? "" : String(out.name);
|
|
1977
|
+
if(!out.extensions || typeof out.extensions !== "object" || Array.isArray(out.extensions)) out.extensions = {};
|
|
1978
|
+
return out;
|
|
1979
|
+
}
|
|
1980
|
+
/** 插件自己的状态挂在 data.extensions.cc(rev 与分组 id)—— 读取都走这里。 */
|
|
1981
|
+
function bookCcOf(obj){
|
|
1982
|
+
const data = (obj && obj.data && typeof obj.data === "object") ? obj.data : {};
|
|
1983
|
+
const ext = (data.extensions && typeof data.extensions === "object") ? data.extensions : {};
|
|
1984
|
+
return (ext.cc && typeof ext.cc === "object") ? ext.cc : {};
|
|
1985
|
+
}
|
|
1986
|
+
function bookRevOf(obj){ const n = Number(bookCcOf(obj).rev); return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; }
|
|
1987
|
+
/** 结构兜底:缺字段补默认、条目逐条归一;`data` 之外的未知顶层字段也保留。 */
|
|
1988
|
+
function normalizeBook(obj, groupId){
|
|
1989
|
+
const src = (obj && typeof obj === "object") ? obj : {};
|
|
1990
|
+
const raw = (src.data && typeof src.data === "object") ? src.data : (Array.isArray(src.entries) ? src : {});
|
|
1991
|
+
const cc = bookCcOf(src);
|
|
1992
|
+
const data = Object.assign({}, raw, {
|
|
1993
|
+
name: String(raw.name || "").slice(0, 80),
|
|
1994
|
+
description: String(raw.description || "").slice(0, 300),
|
|
1995
|
+
scan_depth: Math.max(1, Math.round(Number(raw.scan_depth) || 4)),
|
|
1996
|
+
token_budget: Math.max(100, Math.round(Number(raw.token_budget) || 2000)),
|
|
1997
|
+
recursive_scanning: raw.recursive_scanning === true,
|
|
1998
|
+
entries: (Array.isArray(raw.entries) ? raw.entries : []).slice(0, BOOK_MAX_ENTRIES).map(makeBookEntry),
|
|
1999
|
+
});
|
|
2000
|
+
const ext = Object.assign({}, (raw.extensions && typeof raw.extensions === "object") ? raw.extensions : {});
|
|
2001
|
+
ext.cc = Object.assign({}, (ext.cc && typeof ext.cc === "object") ? ext.cc : {}, {
|
|
2002
|
+
groupId: safeGroupId(groupId || cc.groupId),
|
|
2003
|
+
rev: Math.max(0, Math.round(Number(cc.rev) || 0)),
|
|
2004
|
+
updatedAt: Number(cc.updatedAt) || nowSec(),
|
|
2005
|
+
});
|
|
2006
|
+
data.extensions = ext;
|
|
2007
|
+
return Object.assign({}, src, { spec: BOOK_SPEC, data });
|
|
2008
|
+
}
|
|
2009
|
+
/** 读盘:主根没有就回退旧根(与角色库/分组同一套规则)。 */
|
|
2010
|
+
async function readBookRaw(groupId){
|
|
2011
|
+
const file = safeGroupId(groupId) + ".json";
|
|
2012
|
+
for(const dir of bookDirs()){
|
|
2013
|
+
try{
|
|
2014
|
+
const raw = await readFile(join(dir, file), "utf8");
|
|
2015
|
+
const obj = JSON.parse(raw);
|
|
2016
|
+
if(obj && typeof obj === "object") return { raw, obj };
|
|
2017
|
+
}catch{}
|
|
2018
|
+
}
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
/** 写盘:rev 每次 +1(与 writeGroup 同一套乐观锁语义)。 */
|
|
2022
|
+
async function writeBook(groupId, book){
|
|
2023
|
+
await ensureBookDir();
|
|
2024
|
+
const gid = safeGroupId(groupId);
|
|
2025
|
+
const prev = await readBookRaw(gid);
|
|
2026
|
+
const cur = normalizeBook(book, gid);
|
|
2027
|
+
cur.data.extensions.cc.rev = (prev ? bookRevOf(prev.obj) : 0) + 1;
|
|
2028
|
+
cur.data.extensions.cc.updatedAt = nowSec();
|
|
2029
|
+
await writeFile(bookPath(gid), JSON.stringify(cur, null, 2), "utf8");
|
|
2030
|
+
return cur;
|
|
2031
|
+
}
|
|
2032
|
+
/** 删书:两个根里的同名副本都删(否则回退读会把刚删的书「复活」)。返回是否删到了。 */
|
|
2033
|
+
async function deleteBook(groupId){
|
|
2034
|
+
const gid = safeGroupId(groupId);
|
|
2035
|
+
if(!gid) return false;
|
|
2036
|
+
let removed = 0;
|
|
2037
|
+
for(const dir of bookDirs()){
|
|
2038
|
+
try{ await unlink(join(dir, gid + ".json")); removed += 1; }
|
|
2039
|
+
catch(e){ if(e && e.code !== "ENOENT") throw e; }
|
|
2040
|
+
}
|
|
2041
|
+
return removed > 0;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* 内容签名:折叠空白后取 djb2 哈希 + 长度,用于「同一条设定」的快速比对 —— 只差换行/空格的两条
|
|
2045
|
+
* 不该被当成两条(实测里自动条目与手工条目就常差这点空白)。
|
|
2046
|
+
* ⚠️ 它只是**索引**:真正搬运/删除前一律再逐字比对内容(哈希碰撞绝不能变成删数据)。
|
|
2047
|
+
*/
|
|
2048
|
+
function contentKeyOf(entry){
|
|
2049
|
+
const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
|
|
2050
|
+
let h = 5381;
|
|
2051
|
+
for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
2052
|
+
return "c" + h.toString(36) + "-" + s.length;
|
|
2053
|
+
}
|
|
2054
|
+
/** 成员卡的**完整**条目(去重要比内容,摘要不够)——与 libraryCardsFor 同源,多带 entries。 */
|
|
2055
|
+
async function bookCardsFor(ids){
|
|
2056
|
+
const out = [];
|
|
2057
|
+
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
2058
|
+
try{
|
|
2059
|
+
const obj = JSON.parse((await readLibraryRaw(id)).raw);
|
|
2060
|
+
const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
|
|
2061
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
2062
|
+
out.push({
|
|
2063
|
+
id: String(id),
|
|
2064
|
+
name: String(d.name || (obj && obj.meta && obj.meta.name) || ""),
|
|
2065
|
+
entries: entries.map(makeBookEntry),
|
|
2066
|
+
});
|
|
2067
|
+
}catch{}
|
|
2068
|
+
}
|
|
2069
|
+
return out;
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* 跨成员卡找「同一份设定」(纯函数):按内容签名分组,出现在 **≥2 张卡**里的才算候选。
|
|
2073
|
+
* 只报候选、不下结论 —— 同内容不同名、同名不同内容都如实标注,交给用户/模型拍板。
|
|
2074
|
+
*/
|
|
2075
|
+
function scanSharedEntries(cards){
|
|
2076
|
+
const list = Array.isArray(cards) ? cards : [];
|
|
2077
|
+
const seen = new Map();
|
|
2078
|
+
list.forEach(c => {
|
|
2079
|
+
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2080
|
+
if(!String((e && e.content) || "").trim()) return; // 空内容不会进 prompt,不参与
|
|
2081
|
+
const sig = contentKeyOf(e);
|
|
2082
|
+
if(!seen.has(sig)) seen.set(sig, { signature: sig, content: String(e.content), name: String(e.name || ""), keys: (e.keys || []).map(String), constant: e.constant === true, chars: String(e.content).length, cards: [], cardNames: [], nameMismatch: false, keysMismatch: false });
|
|
2083
|
+
const g = seen.get(sig);
|
|
2084
|
+
if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
|
|
2085
|
+
if(String(e.name || "") !== g.name) g.nameMismatch = true;
|
|
2086
|
+
if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
|
|
2087
|
+
});
|
|
2088
|
+
});
|
|
2089
|
+
const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
|
|
2090
|
+
.sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
|
|
2091
|
+
.map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
|
|
2092
|
+
const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
|
|
2093
|
+
return {
|
|
2094
|
+
candidates,
|
|
2095
|
+
stats: {
|
|
2096
|
+
cards: list.length, totalEntries,
|
|
2097
|
+
duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
|
|
2098
|
+
savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
|
|
2099
|
+
},
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
/** 把共享书的条目合并进一张卡的条目表(同内容已存在则跳过)—— 给不支持全局世界书的客户端兜底。 */
|
|
2103
|
+
function mergeBookEntries(bookEntries, cardEntries){
|
|
2104
|
+
const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
|
|
2105
|
+
const added = [];
|
|
2106
|
+
(Array.isArray(bookEntries) ? bookEntries : []).forEach(e => {
|
|
2107
|
+
const sig = contentKeyOf(e);
|
|
2108
|
+
if(have.has(sig)) return;
|
|
2109
|
+
have.add(sig);
|
|
2110
|
+
added.push(makeBookEntry(e));
|
|
2111
|
+
});
|
|
2112
|
+
return { entries: (Array.isArray(cardEntries) ? cardEntries : []).map(makeBookEntry).concat(added), added };
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* 提升(纯函数):把指定签名的条目写进共享书,并从**每一张**出现过它的成员卡里删掉。
|
|
2116
|
+
* 只删内容逐字相同(折叠空白后相同)的那几条 —— 签名只用来筛,动手前再比对内容。
|
|
2117
|
+
*/
|
|
2118
|
+
function applyBookPromotion(cards, bookEntries, signatures){
|
|
2119
|
+
const want = new Set((Array.isArray(signatures) ? signatures : []).map(String));
|
|
2120
|
+
const book = (Array.isArray(bookEntries) ? bookEntries : []).map(makeBookEntry);
|
|
2121
|
+
const have = new Set(book.map(contentKeyOf));
|
|
2122
|
+
const promoted = [];
|
|
2123
|
+
const nextCards = (Array.isArray(cards) ? cards : []).map(c => {
|
|
2124
|
+
const kept = [];
|
|
2125
|
+
let removed = 0;
|
|
2126
|
+
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2127
|
+
const sig = contentKeyOf(e);
|
|
2128
|
+
if(want.has(sig)){
|
|
2129
|
+
if(!have.has(sig)){ have.add(sig); book.push(makeBookEntry(e)); promoted.push(sig); }
|
|
2130
|
+
removed += 1;
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
kept.push(makeBookEntry(e));
|
|
2134
|
+
});
|
|
2135
|
+
return { id: c.id, name: c.name, entries: kept, removed };
|
|
2136
|
+
});
|
|
2137
|
+
return { bookEntries: book, cards: nextCards, promoted, removedTotal: nextCards.reduce((n, c) => n + c.removed, 0) };
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1751
2140
|
export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclaredPresets, presetTargetRoot, presetSourceRoot,
|
|
1752
2141
|
draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision,
|
|
1753
2142
|
// 落盘根与 lib 层 CRUD(供回归测试直接断言「DSH_HOME 优先 + 读时回退」)
|
|
@@ -1760,6 +2149,11 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
|
|
|
1760
2149
|
groupDirs, groupDir, groupPath, safeGroupId, newGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
1761
2150
|
renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, deleteGroupFile,
|
|
1762
2151
|
groupMemberIds, libraryCardsFor, patchLibraryMeta, orphanCardNodes, syncCardNodeName, estimateTokensLite,
|
|
2152
|
+
// 0.8.0 分组管理:出边口径(渲染与计数共用)、建组查重、归位/建节点 —— 与工具半逐字同构,测试直接比对
|
|
2153
|
+
outgoingEdgesOf, findGroupByName, assignCardToGroup,
|
|
2154
|
+
// 0.9.0 组级共享世界书:落盘 + 纯函数(扫描/提升/回填),两半同构、条目与卡内 character_book 同形
|
|
2155
|
+
BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
|
|
2156
|
+
contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
|
|
1763
2157
|
REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
|
|
1764
2158
|
|
|
1765
2159
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xia-sc/dsh-cc-studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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/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/data-root.test.mjs",
|
|
42
42
|
"prepublishOnly": "npm test"
|
|
43
43
|
},
|
|
44
44
|
"dsh": {
|