@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/agent.js
CHANGED
|
@@ -12,7 +12,7 @@ const name = "dsh-cc-studio-agent";
|
|
|
12
12
|
// 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
|
|
13
13
|
// 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
|
|
14
14
|
// 发版时与 package.json 的 version 同步。
|
|
15
|
-
const BUILD = "0.8.
|
|
15
|
+
const BUILD = "0.8.2";
|
|
16
16
|
const inject = ["tools", "agents"];
|
|
17
17
|
|
|
18
18
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
@@ -481,22 +481,33 @@ function applyGroupOps(group, ops){
|
|
|
481
481
|
g.updatedAt = nowSec();
|
|
482
482
|
return { group: g, applied, rejected };
|
|
483
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* 出边口径(渲染与 outgoing 计数**共用这一处**,避免「预览说 N 条、实际写出 M 条」)。
|
|
486
|
+
* mutual(对等)边是双向的:不管它从哪一端写的,两端都该看到它 —— 所以「指向我的 mutual 边」
|
|
487
|
+
* 也算我的出边。⚠️ 曾经 outgoing 只认 `from === myNodeId`,而 incoming 又显式排除 mutual,
|
|
488
|
+
* 于是 mutual 边**两头都不落**、渲染时被静默丢弃(做的「对等关系」在导出的卡里彻底消失)。
|
|
489
|
+
*/
|
|
490
|
+
function outgoingEdgesOf(myNodeId, edges){
|
|
491
|
+
return (Array.isArray(edges) ? edges : []).filter(e => e.from === myNodeId || (e.direction === "mutual" && e.to === myNodeId));
|
|
492
|
+
}
|
|
484
493
|
function renderRelationsSection(myNodeId, nodes, edges, opts){
|
|
485
494
|
const o = opts || {};
|
|
486
495
|
const byId = new Map((Array.isArray(nodes) ? nodes : []).map(n => [n.id, n]));
|
|
487
496
|
const nameOf = (id) => { const n = byId.get(id); return n ? (n.name || "未命名") : "(未知)"; };
|
|
488
497
|
const all = (Array.isArray(edges) ? edges : []).filter(e => byId.has(e.from) && byId.has(e.to));
|
|
489
|
-
const outgoing =
|
|
498
|
+
const outgoing = outgoingEdgesOf(myNodeId, all);
|
|
499
|
+
// incoming 仍排除 mutual:同一条对等关系不必在两节各说一遍(出边那节已带「,互向」标记)
|
|
490
500
|
const incoming = all.filter(e => e.to === myNodeId && e.direction !== "mutual");
|
|
501
|
+
const otherOf = (e) => (e.from === myNodeId ? e.to : e.from); // mutual 边可能是反向写着的,对方在 from 端
|
|
491
502
|
const order = (t) => { const i = REL_TYPES.indexOf(t); return i < 0 ? REL_TYPES.length : i; };
|
|
492
503
|
const fmt = (e, otherId) => "- " + nameOf(otherId) + "(" + relLabel(e) + "|" + e.strength + "/5" + (e.direction === "mutual" ? ",互向" : "") + ")" + (e.note ? ":" + e.note : "");
|
|
493
|
-
const byTypeThenStrength = (other) => (a, b) => (order(a.type) - order(b.type)) || (b.strength - a.strength) || nameOf(a
|
|
504
|
+
const byTypeThenStrength = (other) => (a, b) => (order(a.type) - order(b.type)) || (b.strength - a.strength) || nameOf(other(a)).localeCompare(nameOf(other(b)));
|
|
494
505
|
const lines = ["【关系】"];
|
|
495
506
|
if(!outgoing.length) lines.push("- (暂无)");
|
|
496
|
-
else outgoing.slice().sort(byTypeThenStrength(
|
|
507
|
+
else outgoing.slice().sort(byTypeThenStrength(otherOf)).forEach(e => lines.push(fmt(e, otherOf(e))));
|
|
497
508
|
if(o.includeIncoming && incoming.length){
|
|
498
509
|
lines.push("", "【他们眼中的我】");
|
|
499
|
-
incoming.slice().sort(byTypeThenStrength(
|
|
510
|
+
incoming.slice().sort(byTypeThenStrength(e => e.from)).forEach(e => lines.push(fmt(e, e.from)));
|
|
500
511
|
}
|
|
501
512
|
return lines.join("\n");
|
|
502
513
|
}
|
|
@@ -634,6 +645,83 @@ async function resolveGroupFor(ctx, exec, explicitGroupId){
|
|
|
634
645
|
return { ok: true, cardId, groupId, group: g, myNode, groups };
|
|
635
646
|
}
|
|
636
647
|
|
|
648
|
+
// —— 0.8.0:分组的增删改查(LLM 侧)——
|
|
649
|
+
// 此前 LLM 只能读写**组内图谱**,改不了「哪张卡属于哪个组」:卡一旦因任何原因掉组,
|
|
650
|
+
// 模型既救不回来也建不了新组(只能干等用户点界面),而真正高频使用这套工具的恰恰是模型。
|
|
651
|
+
// 这四个函数就是那几个工具的实现,落盘规则与宿主半的 cc_createGroup / cc_renameGroup /
|
|
652
|
+
// cc_deleteGroup / cc_setCardGroup 逐条对齐(同一份 cc-groups 目录、同一份库条目 meta.groupId)。
|
|
653
|
+
|
|
654
|
+
/** 按名字找组(建组/改名时查重):trim 后精确比对;带 exceptId 时排除自己(给自己改名不算撞车)。 */
|
|
655
|
+
function findGroupByName(groups, name, exceptId){
|
|
656
|
+
const want = String(name || "").trim();
|
|
657
|
+
if(!want) return null;
|
|
658
|
+
const eid = exceptId ? safeGroupId(exceptId) : "";
|
|
659
|
+
return (Array.isArray(groups) ? groups : []).find(g => g.id !== eid && String(g.name || "").trim() === want) || null;
|
|
660
|
+
}
|
|
661
|
+
/** 每个组一行摘要:成员数由库条目 meta.groupId **派生**(与 groupMemberIds 同一套口径)。 */
|
|
662
|
+
async function groupSummaries(){
|
|
663
|
+
const groups = await listGroups();
|
|
664
|
+
const entries = await listLibraryEntries();
|
|
665
|
+
const countOf = (gid) => entries.filter(e => String(e.groupId || "") === gid).length;
|
|
666
|
+
return groups.map(g => ({
|
|
667
|
+
id: g.id, name: g.name, note: g.note,
|
|
668
|
+
nodeCount: g.nodes.length, edgeCount: g.edges.length,
|
|
669
|
+
memberCount: countOf(g.id), rev: g.rev, updatedAt: g.updatedAt,
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* 把一张已存卡放进分组(groupId 传空 = 移出)。入组时若组里还没有它的节点就自动补一个 ——
|
|
674
|
+
* 调用方只需要表达「把这张卡放进这个组」,不必先手工建节点。
|
|
675
|
+
* 移出**不删节点、不删关系**:那是用户建过的资产,挪回来时关系还在(与「删卡降级 orphan、保留边」同一原则)。
|
|
676
|
+
*/
|
|
677
|
+
async function assignCardToGroup(cardId, groupId){
|
|
678
|
+
const cid = String(cardId || "");
|
|
679
|
+
if(!cid) throw new Error("需要 cardId");
|
|
680
|
+
const entries = await listLibraryEntries();
|
|
681
|
+
const mine = entries.find(e => e.id === cid);
|
|
682
|
+
if(!mine) throw Object.assign(new Error("找不到角色卡: " + cid), { code: "ENOENT" });
|
|
683
|
+
const gid = (groupId === null || groupId === undefined || groupId === "") ? "" : safeGroupId(groupId);
|
|
684
|
+
if(!gid){
|
|
685
|
+
await patchLibraryMeta(cid, { groupId: null });
|
|
686
|
+
return { cardId: cid, cardName: mine.name, previousGroupId: (mine.groupId ? String(mine.groupId) : null), groupId: null, nodeId: null };
|
|
687
|
+
}
|
|
688
|
+
const found = await readGroupRaw(gid);
|
|
689
|
+
if(!found) throw Object.assign(new Error("分组不存在: " + gid), { code: "not-found" });
|
|
690
|
+
const g = normalizeGroup(found.obj);
|
|
691
|
+
let node = g.nodes.find(n => n.kind === "card" && n.cardId === cid);
|
|
692
|
+
let revived = false;
|
|
693
|
+
if(!node){
|
|
694
|
+
// 卡曾经被删(节点被 orphanCardNodes 降级成 orphan)后又同名回来时**复活那个节点**,
|
|
695
|
+
// 让它原来的关系继续有效;否则会新建第二个同名节点 —— 体检立刻报 duplicate-name,
|
|
696
|
+
// 而旧节点上挂着的那些关系永远悬空(关系是用户资产,不该被一次误删永久切断)。
|
|
697
|
+
node = g.nodes.find(n => n.kind === "card" && !n.cardId && n.orphanOf && n.orphanOf === String(mine.name || ""));
|
|
698
|
+
if(node){ node.cardId = cid; node.orphanOf = null; revived = true; }
|
|
699
|
+
}
|
|
700
|
+
if(!node){
|
|
701
|
+
node = { id: "n" + (g.nextNodeSeq++), kind: "card", cardId: cid, name: mine.name || "未命名角色", aliases: [], orphanOf: null };
|
|
702
|
+
g.nodes.push(node);
|
|
703
|
+
await writeGroup(g);
|
|
704
|
+
} else if(revived){
|
|
705
|
+
await writeGroup(g);
|
|
706
|
+
}
|
|
707
|
+
await patchLibraryMeta(cid, { groupId: gid });
|
|
708
|
+
return { cardId: cid, cardName: mine.name, previousGroupId: (mine.groupId ? String(mine.groupId) : null), groupId: gid, nodeId: node.id, groupRev: g.rev, revived };
|
|
709
|
+
}
|
|
710
|
+
/** 删组:组文件(节点 + 关系)不可恢复;成员卡**不删**,只清掉它们指向该组的 meta.groupId。组不存在返回 null。 */
|
|
711
|
+
async function deleteGroupWithMembers(groupId){
|
|
712
|
+
const gid = safeGroupId(groupId);
|
|
713
|
+
const found = await readGroupRaw(gid);
|
|
714
|
+
if(!found) return null;
|
|
715
|
+
const g = normalizeGroup(found.obj);
|
|
716
|
+
const members = await groupMemberIds(gid);
|
|
717
|
+
await unlink(groupPath(gid));
|
|
718
|
+
let memberCleared = 0;
|
|
719
|
+
for(const id of members){
|
|
720
|
+
try{ await patchLibraryMeta(id, { groupId: null }); memberCleared += 1; }catch{}
|
|
721
|
+
}
|
|
722
|
+
return { deleted: { id: g.id, name: g.name, nodeCount: g.nodes.length, edgeCount: g.edges.length }, memberCleared };
|
|
723
|
+
}
|
|
724
|
+
|
|
637
725
|
// —— 已存角色库:<主根>/cc-library/<id>.json(读盘回退 0.3.6 之前的 homedir()/.dsh)——
|
|
638
726
|
function libDirs(){ return dataRoots("cc-library"); }
|
|
639
727
|
function libDir(){ return libDirs()[0]; }
|
|
@@ -663,6 +751,12 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
|
663
751
|
name:(data.name && String(data.name).trim()) || (existingMeta && existingMeta.name) || "未命名角色",
|
|
664
752
|
loreCount, tags, tagsTotal: allTags.length,
|
|
665
753
|
descriptionSnippet:String(data.description||"").slice(0,80),
|
|
754
|
+
// 0.8.0 关系图谱:卡属于哪个分组(**单一真源** —— 分组文件里不存成员列表,避免双写漂移)。
|
|
755
|
+
// 继承既有值,所以「改卡内容再保存」不会把卡踢出分组。
|
|
756
|
+
// ⚠️ 这一行曾经**只有宿主半有**:工具半重建 meta 时把 groupId 抹掉,表现为「LLM 侧保存一次,
|
|
757
|
+
// 卡就静默退组」(返回仍是 action:"updated",零提示),而关系工具在卡退组后全部拒绝服务。
|
|
758
|
+
// 两半的 makeLibraryEntryMeta 必须逐字同构 —— 加字段时两边一起加(tests/relations.test.mjs 钉住)。
|
|
759
|
+
groupId: (existingMeta && existingMeta.groupId) ? String(existingMeta.groupId) : null,
|
|
666
760
|
createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
|
|
667
761
|
updatedAt: now,
|
|
668
762
|
};
|
|
@@ -740,6 +834,16 @@ async function renameLibraryEntry(id,newName){
|
|
|
740
834
|
await writeFile(found.path, JSON.stringify(obj,null,2), "utf8");
|
|
741
835
|
return obj.meta;
|
|
742
836
|
}
|
|
837
|
+
/** 0.8.0:就地改某个库条目的 meta(写在解析到的那一份上,与 renameLibraryEntry 同规,不搬家)。与 lib/index.js 同构。 */
|
|
838
|
+
async function patchLibraryMeta(id,patch){
|
|
839
|
+
const found=await readLibraryRaw(id);
|
|
840
|
+
const obj=JSON.parse(found.raw);
|
|
841
|
+
if(!obj || !obj.meta) throw new Error("文件格式错误");
|
|
842
|
+
Object.assign(obj.meta, patch||{});
|
|
843
|
+
obj.meta.updatedAt=nowSec();
|
|
844
|
+
await writeFile(found.path, JSON.stringify(obj,null,2), "utf8");
|
|
845
|
+
return obj;
|
|
846
|
+
}
|
|
743
847
|
|
|
744
848
|
function out(schema){ return { schema, render: (args, result) => [{ type:"text", text: JSON.stringify(result, null, 2) }] }; }
|
|
745
849
|
|
|
@@ -1745,7 +1849,9 @@ function apply(ctx){
|
|
|
1745
1849
|
const before = String((cur.data && cur.data.description) || "");
|
|
1746
1850
|
const after = spliceRelations(before, section, "rev=" + g.rev + " group=" + g.id);
|
|
1747
1851
|
const changed = before !== after;
|
|
1748
|
-
|
|
1852
|
+
// 计数必须与渲染**同一口径**(outgoingEdgesOf 含「指向我的 mutual 边」),否则会出现
|
|
1853
|
+
// 「预览说 1 条、实际写出 2 条」这种对不上账的返回。
|
|
1854
|
+
const outgoing = outgoingEdgesOf(r.myNode.id, g.edges).length;
|
|
1749
1855
|
if(args.dryRun === true){
|
|
1750
1856
|
return { ok:true, dryRun:true, changed, outgoing, section, preview: after,
|
|
1751
1857
|
group: { id: g.id, name: g.name, rev: g.rev },
|
|
@@ -1795,6 +1901,363 @@ function apply(ctx){
|
|
|
1795
1901
|
}
|
|
1796
1902
|
});
|
|
1797
1903
|
|
|
1904
|
+
// ———————————————— 分组管理(0.8.0 补:LLM 侧的增删改查)————————————————
|
|
1905
|
+
// 此前 LLM 只能读写**组内图谱**,却改不了「哪张卡属于哪个组」:卡一旦掉组,模型既救不回来、
|
|
1906
|
+
// 也建不了新组,只能干等用户点界面 —— 而真正高频使用这套工具的恰恰是模型(用户自己说的)。
|
|
1907
|
+
// 四个动作(查/增/改/删)+ 一个归位,落盘规则与宿主半的 cc_listGroups / cc_createGroup /
|
|
1908
|
+
// cc_renameGroup / cc_deleteGroup / cc_setCardGroup 逐条对齐。
|
|
1909
|
+
|
|
1910
|
+
tool({
|
|
1911
|
+
name: "cc_list_groups",
|
|
1912
|
+
description: "【分组·查】列出全部人物关系分组:每组有多少成员卡 / 节点 / 关系,以及**当前卡在哪一组**。改某张卡的分组用 cc_set_card_group;建组 / 改名 / 删组用 cc_create_group / cc_rename_group / cc_delete_group。",
|
|
1913
|
+
parameters: {
|
|
1914
|
+
type:"object",
|
|
1915
|
+
properties:{ includeCards:{ type:"boolean", description:"是否额外列出未分组的卡(默认 true,最多 20 张)" } },
|
|
1916
|
+
additionalProperties:false
|
|
1917
|
+
},
|
|
1918
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1919
|
+
async execute(args, exec){
|
|
1920
|
+
const k = keyFor(ctx, exec);
|
|
1921
|
+
const origin = readOriginSync(k);
|
|
1922
|
+
const cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
1923
|
+
const entries = await listLibraryEntries();
|
|
1924
|
+
const mine = cardId ? (entries.find(e => e.id === cardId) || null) : null;
|
|
1925
|
+
const groups = await groupSummaries();
|
|
1926
|
+
const ungrouped = entries.filter(e => !String(e.groupId || ""));
|
|
1927
|
+
const myGroupId = (mine && mine.groupId) ? String(mine.groupId) : null;
|
|
1928
|
+
const myGroup = myGroupId ? (groups.find(g => g.id === myGroupId) || null) : null;
|
|
1929
|
+
const res = {
|
|
1930
|
+
ok:true,
|
|
1931
|
+
groups: groups.map(g => Object.assign({}, g, { isMine: g.id === myGroupId })),
|
|
1932
|
+
groupCount: groups.length, cardCount: entries.length, ungroupedCount: ungrouped.length,
|
|
1933
|
+
cardId: cardId || null, cardName: mine ? mine.name : "", groupId: myGroupId,
|
|
1934
|
+
message: groups.length
|
|
1935
|
+
? "共 " + groups.length + " 个分组、库里 " + entries.length + " 张卡(" + ungrouped.length + " 张未分组)"
|
|
1936
|
+
+ (mine
|
|
1937
|
+
? ";当前卡「" + mine.name + "」" + (myGroup
|
|
1938
|
+
? "在分组「" + myGroup.name + "」里"
|
|
1939
|
+
: (myGroupId ? "的 groupId 指向一个已不存在的分组(" + myGroupId + "),建议用 cc_set_card_group 重新归位" : "还没有加入分组"))
|
|
1940
|
+
: "")
|
|
1941
|
+
+ "。"
|
|
1942
|
+
: "还没有任何分组" + (entries.length ? "(库里有 " + entries.length + " 张卡)" : "") + ":用 cc_create_group 建一个,再用 cc_set_card_group 把卡放进去。",
|
|
1943
|
+
};
|
|
1944
|
+
if(args.includeCards !== false) res.ungroupedCards = ungrouped.slice(0, 20).map(e => ({ id: e.id, name: e.name }));
|
|
1945
|
+
return res;
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
tool({
|
|
1950
|
+
name: "cc_create_group",
|
|
1951
|
+
description: "【分组·增】新建一个人物关系分组(群聊 / 同一世界观的一批卡放一起)。分组是插件侧数据、不会自动进卡 —— 关系要进卡请再调 cc_render_relations。同名分组已存在时**默认拒绝**并回已有 id(避免出现两个同名组);确实要重名再传 allowDuplicate:true。",
|
|
1952
|
+
parameters: {
|
|
1953
|
+
type:"object",
|
|
1954
|
+
properties:{
|
|
1955
|
+
name:{ type:"string", description:"分组名(≤60 字)" },
|
|
1956
|
+
note:{ type:"string", description:"可选备注(≤200 字)" },
|
|
1957
|
+
allowDuplicate:{ type:"boolean", description:"默认 false:同名分组已存在则拒绝并告知 existingGroupId" },
|
|
1958
|
+
},
|
|
1959
|
+
required:["name"],
|
|
1960
|
+
additionalProperties:false
|
|
1961
|
+
},
|
|
1962
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1963
|
+
async execute(args){
|
|
1964
|
+
const name = String(args.name || "").trim().slice(0, 60);
|
|
1965
|
+
if(!name) return { ok:false, error:"分组名不能为空" };
|
|
1966
|
+
const dup = findGroupByName(await listGroups(), name, "");
|
|
1967
|
+
if(dup && args.allowDuplicate !== true){
|
|
1968
|
+
return { ok:false, duplicate:true, existingGroupId: dup.id, groups: await groupSummaries(),
|
|
1969
|
+
message:"已有同名分组「" + dup.name + "」(id " + dup.id + ")—— 直接把卡放进去就行:cc_set_card_group({groupId:\"" + dup.id + "\"});确实要另建一个同名的再传 allowDuplicate:true。" };
|
|
1970
|
+
}
|
|
1971
|
+
const g = await writeGroup({ id: newGroupId(), name, note: String(args.note || "").slice(0, 200), nodes: [], edges: [] });
|
|
1972
|
+
return { ok:true, group: { id:g.id, name:g.name, note:g.note, rev:g.rev },
|
|
1973
|
+
groups: await groupSummaries(),
|
|
1974
|
+
message:"已建分组「" + g.name + "」(id " + g.id + ")。用 cc_set_card_group 把卡放进去,再用 cc_patch_relations 在组内建关系。" };
|
|
1975
|
+
}
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
tool({
|
|
1979
|
+
name: "cc_rename_group",
|
|
1980
|
+
description: "【分组·改】改分组名或备注(至少传一个)。只改分组本身,不动成员卡与关系;改成与别的分组同名时默认拒绝,除非 allowDuplicate:true。",
|
|
1981
|
+
parameters: {
|
|
1982
|
+
type:"object",
|
|
1983
|
+
properties:{
|
|
1984
|
+
groupId:{ type:"string", description:"分组 id(见 cc_list_groups)" },
|
|
1985
|
+
name:{ type:"string", description:"新分组名(≤60 字)" },
|
|
1986
|
+
note:{ type:"string", description:"新备注(≤200 字;空串 = 清空备注)" },
|
|
1987
|
+
allowDuplicate:{ type:"boolean", description:"默认 false:与别的分组撞名则拒绝" },
|
|
1988
|
+
},
|
|
1989
|
+
required:["groupId"],
|
|
1990
|
+
additionalProperties:false
|
|
1991
|
+
},
|
|
1992
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1993
|
+
async execute(args){
|
|
1994
|
+
const gid = safeGroupId(args.groupId);
|
|
1995
|
+
const found = await readGroupRaw(gid);
|
|
1996
|
+
if(!found) return { ok:false, notFound:true, groups: await groupSummaries(), message:"分组不存在:" + String(args.groupId) + " —— 先 cc_list_groups 看现有分组。" };
|
|
1997
|
+
const g = normalizeGroup(found.obj);
|
|
1998
|
+
const wantName = (typeof args.name === "string" && args.name.trim()) ? args.name.trim().slice(0, 60) : "";
|
|
1999
|
+
const wantNote = (typeof args.note === "string") ? args.note.slice(0, 200) : null;
|
|
2000
|
+
if(!wantName && wantNote === null) return { ok:false, error:"至少传 name 或 note 之一" };
|
|
2001
|
+
if(wantName){
|
|
2002
|
+
const dup = findGroupByName(await listGroups(), wantName, gid);
|
|
2003
|
+
if(dup && args.allowDuplicate !== true){
|
|
2004
|
+
return { ok:false, duplicate:true, existingGroupId: dup.id, groups: await groupSummaries(),
|
|
2005
|
+
message:"已有同名分组「" + dup.name + "」(id " + dup.id + ")—— 换个名字,或确实要重名再传 allowDuplicate:true。" };
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
const changed = [];
|
|
2009
|
+
if(wantName && wantName !== g.name){ changed.push("name"); g.name = wantName; }
|
|
2010
|
+
if(wantNote !== null && wantNote !== g.note){ changed.push("note"); g.note = wantNote; }
|
|
2011
|
+
// 无实际改动不写盘:同 N4 家规(空改动不该白涨 rev,否则别人手上的 rev 前提会被无变化打掉)
|
|
2012
|
+
if(!changed.length) return { ok:true, changed:[], group:{ id:g.id, name:g.name, note:g.note, rev:g.rev }, message:"分组「" + g.name + "」的名字与备注本来就是这两样,没有改动。" };
|
|
2013
|
+
const saved = await writeGroup(g);
|
|
2014
|
+
return { ok:true, changed, group:{ id:saved.id, name:saved.name, note:saved.note, rev:saved.rev },
|
|
2015
|
+
groups: await groupSummaries(),
|
|
2016
|
+
message:"已改分组「" + saved.name + "」的 " + changed.join(" / ") + "(rev=" + saved.rev + ")。" };
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
tool({
|
|
2021
|
+
name: "cc_delete_group",
|
|
2022
|
+
description: "【分组·删·破坏性】删除一个人物关系分组:**组里的节点与关系会一起消失且不可恢复**;成员卡本身不会被删,只是离开这个组。默认必须带 confirm:true 才真的执行 —— 第一次调用只回该组有多少节点/关系,供你先与用户确认。",
|
|
2023
|
+
parameters: {
|
|
2024
|
+
type:"object",
|
|
2025
|
+
properties:{
|
|
2026
|
+
groupId:{ type:"string", description:"分组 id(见 cc_list_groups)" },
|
|
2027
|
+
confirm:{ type:"boolean", description:"必须显式传 true 才执行删除" },
|
|
2028
|
+
},
|
|
2029
|
+
required:["groupId"],
|
|
2030
|
+
additionalProperties:false
|
|
2031
|
+
},
|
|
2032
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2033
|
+
async execute(args){
|
|
2034
|
+
const gid = safeGroupId(args.groupId);
|
|
2035
|
+
const found = await readGroupRaw(gid);
|
|
2036
|
+
if(!found) return { ok:false, notFound:true, groups: await groupSummaries(), message:"分组不存在:" + String(args.groupId) + " —— 先 cc_list_groups 看现有分组。" };
|
|
2037
|
+
const g = normalizeGroup(found.obj);
|
|
2038
|
+
const members = await groupMemberIds(gid);
|
|
2039
|
+
if(args.confirm !== true){
|
|
2040
|
+
return { ok:false, needConfirm:true,
|
|
2041
|
+
group: { id:g.id, name:g.name, nodeCount:g.nodes.length, edgeCount:g.edges.length, memberCount:members.length },
|
|
2042
|
+
message:"「" + g.name + "」里有 " + g.nodes.length + " 个节点 / " + g.edges.length + " 条关系,删组会一起删掉且不可恢复;" + members.length + " 张成员卡不会被删,只会离开这个组。与用户确认后重调并带 confirm:true。" };
|
|
2043
|
+
}
|
|
2044
|
+
const del = await deleteGroupWithMembers(gid);
|
|
2045
|
+
const bookDeleted = await deleteBook(gid); // 0.9.0:组级共享世界书随组一起删(留下就是孤儿文件)
|
|
2046
|
+
return { ok:true, deleted: del.deleted, memberCleared: del.memberCleared, bookDeleted,
|
|
2047
|
+
groups: await groupSummaries(),
|
|
2048
|
+
message:"已删除分组「" + del.deleted.name + "」(连同 " + del.deleted.nodeCount + " 个节点 / " + del.deleted.edgeCount + " 条关系);" + del.memberCleared + " 张成员卡已移出分组,卡本身没删。"
|
|
2049
|
+
+ (bookDeleted ? "该组的共享世界书也一并删除了。" : "") };
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
tool({
|
|
2054
|
+
name: "cc_set_card_group",
|
|
2055
|
+
description: "【分组·归位】把一张已存卡放进分组 / 换组 / 移出。**不传 groupId(或传 null / 空串)= 移出分组**。cardId 省略时用**当前草稿的来源卡**(你正在做的那张)。入组时若组里还没有它的节点会自动补一个;移出**不删节点与关系**(以后放回来关系还在)。卡因任何原因掉了组,也用它自救。",
|
|
2056
|
+
parameters: {
|
|
2057
|
+
type:"object",
|
|
2058
|
+
properties:{
|
|
2059
|
+
groupId:{ type:"string", description:"目标分组 id(见 cc_list_groups)。**不传 / null / 空串 = 移出分组**" },
|
|
2060
|
+
cardId:{ type:"string", description:"可选:要归位的已存卡 id(见 cc_list_library);默认用当前草稿的来源卡" },
|
|
2061
|
+
},
|
|
2062
|
+
additionalProperties:false
|
|
2063
|
+
},
|
|
2064
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2065
|
+
async execute(args, exec){
|
|
2066
|
+
const entries = await listLibraryEntries();
|
|
2067
|
+
const k = keyFor(ctx, exec);
|
|
2068
|
+
const origin = readOriginSync(k);
|
|
2069
|
+
const fromOrigin = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
2070
|
+
const cardId = (typeof args.cardId === "string" && args.cardId.trim()) ? args.cardId.trim() : fromOrigin;
|
|
2071
|
+
if(!cardId){
|
|
2072
|
+
const ungrouped = entries.filter(e => !String(e.groupId || ""));
|
|
2073
|
+
return { ok:false, needCardId:true, cardId:null,
|
|
2074
|
+
groups: await groupSummaries(),
|
|
2075
|
+
cards: entries.slice(0, 20).map(e => ({ id:e.id, name:e.name, groupId: e.groupId ? String(e.groupId) : null })),
|
|
2076
|
+
message:"当前草稿还没入库(没有来源卡 id),不知道要归位哪张卡:先用 cc_save_to_library 落库,或从 cc_list_library 拿到 id 后带上 cardId 重调。"
|
|
2077
|
+
+ (ungrouped.length ? " 库里现在有 " + ungrouped.length + " 张未分组的卡。" : "") };
|
|
2078
|
+
}
|
|
2079
|
+
let r;
|
|
2080
|
+
try{ r = await assignCardToGroup(cardId, (args.groupId === null || args.groupId === undefined) ? null : String(args.groupId)); }
|
|
2081
|
+
catch(e){
|
|
2082
|
+
const code = e && e.code;
|
|
2083
|
+
if(code === "ENOENT") return { ok:false, notFound:true, cardId, cards: entries.slice(0, 20).map(x => ({ id:x.id, name:x.name })),
|
|
2084
|
+
message:"库里找不到这张卡:" + cardId + "(可能已被删除)—— 卡 id 见 cc_list_library。" };
|
|
2085
|
+
if(code === "not-found") return { ok:false, notFound:true, groupId: String(args.groupId), groups: await groupSummaries(),
|
|
2086
|
+
message:"分组不存在:" + String(args.groupId) + " —— 先 cc_create_group 建组,或 cc_list_groups 看现有 id。" };
|
|
2087
|
+
throw e;
|
|
2088
|
+
}
|
|
2089
|
+
const groups = await groupSummaries();
|
|
2090
|
+
const g = r.groupId ? (groups.find(x => x.id === r.groupId) || null) : null;
|
|
2091
|
+
return { ok:true,
|
|
2092
|
+
cardId: r.cardId, cardName: r.cardName,
|
|
2093
|
+
groupId: r.groupId, group: g ? { id:g.id, name:g.name, rev:g.rev, memberCount:g.memberCount } : null,
|
|
2094
|
+
previousGroupId: r.previousGroupId, nodeId: r.nodeId || null, revived: r.revived === true,
|
|
2095
|
+
groups,
|
|
2096
|
+
message: r.groupId
|
|
2097
|
+
? "已把「" + r.cardName + "」放进分组「" + (g ? g.name : r.groupId) + "」"
|
|
2098
|
+
+ (r.previousGroupId && r.previousGroupId !== r.groupId ? "(从分组 " + r.previousGroupId + " 换过来的)" : "")
|
|
2099
|
+
+ ",这组现在有 " + (g ? g.memberCount : "?") + " 张成员卡。关系用 cc_patch_relations 改,写完用 cc_render_relations 进卡。"
|
|
2100
|
+
: "已把「" + r.cardName + "」移出分组"
|
|
2101
|
+
+ (r.previousGroupId ? "(原来在分组 " + r.previousGroupId + ")" : "")
|
|
2102
|
+
+ "。组里的节点与关系**保留**了 —— 之后放回去,关系还在。" };
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
// ———————————————— 组级共享世界书(0.9.0)————————————————
|
|
2107
|
+
// 一组一份:世界设定(年表/势力/地理/力量/日常)提到共享书,成员卡里只留角色私有条目。
|
|
2108
|
+
// 导出后是 CCv3 官方 lorebook_v3 JSON,在客户端里绑成全局世界书即可,多卡共用、零重复。
|
|
2109
|
+
|
|
2110
|
+
tool({
|
|
2111
|
+
name: "cc_get_shared_book",
|
|
2112
|
+
description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」—— 那些就是该提升到共享书的。共享书是独立文件(CCv3 官方 lorebook_v3 格式),导出后可在客户端里绑成全局世界书,于是每张成员卡里不必再存这些设定。",
|
|
2113
|
+
parameters: {
|
|
2114
|
+
type:"object",
|
|
2115
|
+
properties:{
|
|
2116
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
2117
|
+
includeContent:{ type:"boolean", description:"true = 连条目全文一起回(默认只回 name/keys/长度/前 80 字,省上下文)" },
|
|
2118
|
+
},
|
|
2119
|
+
additionalProperties:false
|
|
2120
|
+
},
|
|
2121
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2122
|
+
async execute(args, exec){
|
|
2123
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
2124
|
+
if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
2125
|
+
message:"当前卡还没入库或没加入分组,而共享世界书是按组存放的 —— 先 cc_create_group / cc_set_card_group 把它归到一组。" };
|
|
2126
|
+
const g = r.group;
|
|
2127
|
+
const found = await readBookRaw(g.id);
|
|
2128
|
+
const cards = await bookCardsFor(await groupMemberIds(g.id));
|
|
2129
|
+
const scan = scanSharedEntries(cards);
|
|
2130
|
+
const duplicates = scan.candidates.map(c => ({ signature: c.signature, name: c.name, cardNames: c.cardNames,
|
|
2131
|
+
chars: c.chars, savedChars: c.savedChars, nameMismatch: c.nameMismatch, keysMismatch: c.keysMismatch, preview: c.preview }));
|
|
2132
|
+
const base = { ok:true, group:{ id:g.id, name:g.name, rev:g.rev }, exists: !!found,
|
|
2133
|
+
bookRev: found ? bookRevOf(normalizeBook(found.obj, g.id)) : 0,
|
|
2134
|
+
cards: cards.map(c => ({ id:c.id, name:c.name, entries:(c.entries||[]).length })),
|
|
2135
|
+
duplicates, duplicateStats: scan.stats };
|
|
2136
|
+
if(!found){
|
|
2137
|
+
return Object.assign(base, { message: "分组「" + g.name + "」还没有共享世界书。"
|
|
2138
|
+
+ (scan.candidates.length
|
|
2139
|
+
? "成员卡之间有 " + scan.candidates.length + " 条内容重复的条目(合计可省约 " + scan.stats.savedChars + " 字符)—— 用 cc_promote_shared_entries 提升到共享书,再从各卡里删掉。"
|
|
2140
|
+
: "成员卡之间也没有内容重复的条目。") });
|
|
2141
|
+
}
|
|
2142
|
+
const book = normalizeBook(found.obj, g.id);
|
|
2143
|
+
const tokens = estimateTokens(book.data.entries.map(e => e.content).join("\n"));
|
|
2144
|
+
return Object.assign(base, {
|
|
2145
|
+
book: bookBrief(book, args.includeContent === true), tokens,
|
|
2146
|
+
message: "共享世界书「" + book.data.name + "」共 " + book.data.entries.length + " 条(rev " + bookRevOf(book) + ",约 " + tokens + " token"
|
|
2147
|
+
+ ",预算 " + book.data.token_budget + ");分组里有 " + cards.length + " 张成员卡"
|
|
2148
|
+
+ (scan.candidates.length ? ",其中仍有 " + scan.candidates.length + " 条内容重复(可用 cc_promote_shared_entries 收进共享书)" : ",卡片间已无重复条目") + "。" });
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
tool({
|
|
2153
|
+
name: "cc_patch_shared_book",
|
|
2154
|
+
description: "【共享书·改】增删改组级共享世界书的条目与书级参数。ops:{op:\"addEntry\",entry:{keys,content,name?,constant?,insertion_order?,use_regex?,...}} / {op:\"updateEntry\",contentKey,patch:{…}} / {op:\"removeEntry\",contentKey}(contentKey 由 cc_get_shared_book 给出)。书级参数直接传 name / description / scan_depth / token_budget / recursive_scanning。条目字段与卡内 character_book 条目**完全同构**(改完可以原样搬回卡里)。只改共享书本身 —— 成员卡里已有的旧副本要用 cc_promote_shared_entries 清掉。",
|
|
2155
|
+
parameters: {
|
|
2156
|
+
type:"object",
|
|
2157
|
+
properties:{
|
|
2158
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
2159
|
+
name:{ type:"string", description:"书级:书名" },
|
|
2160
|
+
description:{ type:"string", description:"书级:备注" },
|
|
2161
|
+
scan_depth:{ type:"number", description:"书级:扫描深度" },
|
|
2162
|
+
token_budget:{ type:"number", description:"书级:token 预算" },
|
|
2163
|
+
recursive_scanning:{ type:"boolean", description:"书级:递归扫描" },
|
|
2164
|
+
ops:{ type:"array", items:{ type:"object", additionalProperties:true }, description:"条目动作数组" },
|
|
2165
|
+
expectedBookRev:{ type:"number", description:"可选乐观锁:传上次 cc_get_shared_book 的 bookRev(与 expectedRev / expectedGroupRev 都不同:那是草稿 / 图谱的版本)" },
|
|
2166
|
+
},
|
|
2167
|
+
additionalProperties:false
|
|
2168
|
+
},
|
|
2169
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2170
|
+
async execute(args, exec){
|
|
2171
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
2172
|
+
if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
2173
|
+
message:"当前卡还没入库或没加入分组,共享世界书无处可放 —— 先把它归到一组。" };
|
|
2174
|
+
const res = await patchSharedBook(r.group.id, args);
|
|
2175
|
+
if(!res.ok){
|
|
2176
|
+
return { ok:false, staleRev: res.staleRev === true,
|
|
2177
|
+
expectedBookRev: res.expectedBookRev == null ? null : res.expectedBookRev,
|
|
2178
|
+
actualBookRev: res.actualBookRev == null ? null : res.actualBookRev,
|
|
2179
|
+
error: String(res.error || "") };
|
|
2180
|
+
}
|
|
2181
|
+
const entries = res.book.data.entries.length;
|
|
2182
|
+
return { ok:true, changed: res.changed, applied: res.applied, rejected: res.rejected,
|
|
2183
|
+
bookRev: res.rev, group:{ id:r.group.id, name:r.group.name },
|
|
2184
|
+
book: { name: res.book.data.name, entries, tokens: estimateTokens(res.book.data.entries.map(e => e.content).join("\n")) },
|
|
2185
|
+
message: res.changed
|
|
2186
|
+
? "已改共享世界书(rev " + res.rev + ",共 " + entries + " 条)" + (res.rejected.length ? ";" + res.rejected.length + " 项被拒:" + res.rejected.map(x => x.error).join(";") : "")
|
|
2187
|
+
+ "。注意:成员卡里如果还留着这些条目的旧副本,用 cc_promote_shared_entries 清掉才不会重复。"
|
|
2188
|
+
: "没有任何改动(" + (res.rejected.length ? res.rejected.map(x => x.error).join(";") : "ops 为空且没有书级参数") + ")。" };
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
tool({
|
|
2193
|
+
name: "cc_promote_shared_entries",
|
|
2194
|
+
description: "【共享书·去重】把成员卡里**内容重复的条目**提升进共享世界书,并从每一张出现过它的卡里删掉 —— 这就是「群聊卡世界书重复」的正解。**默认只回预览(dryRun)**,要真动手必须传 apply:true。不带 keys 时处理扫描出的全部候选;也可只传其中几个 signature。执行前会逐字比对内容(签名只用来筛,绝不按哈希删数据)。",
|
|
2195
|
+
parameters: {
|
|
2196
|
+
type:"object",
|
|
2197
|
+
properties:{
|
|
2198
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
2199
|
+
keys:{ type:"array", items:{ type:"string" }, description:"可选:只处理这些 signature(来自 cc_get_shared_book 的 duplicates)" },
|
|
2200
|
+
apply:{ type:"boolean", description:"必须显式 true 才真的写盘(要从多张卡里删条目)" },
|
|
2201
|
+
},
|
|
2202
|
+
additionalProperties:false
|
|
2203
|
+
},
|
|
2204
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2205
|
+
async execute(args, exec){
|
|
2206
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
2207
|
+
if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
2208
|
+
message:"当前卡还没入库或没加入分组,共享世界书按组存放 —— 先把它归到一组。" };
|
|
2209
|
+
const res = await promoteSharedBook(r.group.id, args);
|
|
2210
|
+
if(!res.ok) return { ok:false, error: String(res.error || "") };
|
|
2211
|
+
if(res.dryRun){
|
|
2212
|
+
return { ok:true, dryRun:true, wouldPromote: res.wouldPromote, unknownKeys: res.unknownKeys,
|
|
2213
|
+
removedTotal: res.removedTotal, perCard: res.perCard, duplicates: res.candidates, stats: res.stats,
|
|
2214
|
+
group:{ id:r.group.id, name:r.group.name }, bookRev: res.rev,
|
|
2215
|
+
message: res.wouldPromote
|
|
2216
|
+
? "预览:将把 " + res.wouldPromote + " 条提升进共享世界书,并从成员卡里共删掉 " + res.removedTotal + " 条重复(可省约 " + res.stats.savedChars + " 字符)。确认后带 apply:true 重调。"
|
|
2217
|
+
: "没有可提升的重复条目" + (res.unknownKeys.length ? "(" + res.unknownKeys.length + " 个 signature 不认识,已忽略)" : "") + "。" };
|
|
2218
|
+
}
|
|
2219
|
+
return { ok:true, dryRun:false, promoted: res.promoted, removedTotal: res.removedTotal, unknownKeys: res.unknownKeys,
|
|
2220
|
+
bookRev: res.bookRev, bookEntries: res.bookEntries, perCard: res.perCard, stats: res.stats,
|
|
2221
|
+
group:{ id:r.group.id, name:r.group.name },
|
|
2222
|
+
message: "已把 " + res.promoted + " 条设定提升进共享世界书(rev " + res.bookRev + ",共 " + res.bookEntries + " 条),并从成员卡里删掉 " + res.removedTotal + " 条重复:"
|
|
2223
|
+
+ res.perCard.map(c => c.name + " -" + (c.removed || 0)).join("、")
|
|
2224
|
+
+ "。导出时记得一并给客户端配这本共享世界书(工坊导出页可导出独立 JSON)。" };
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
tool({
|
|
2229
|
+
name: "cc_apply_shared_book",
|
|
2230
|
+
description: "【共享书·回填】把共享世界书的条目**合并进当前卡**(或指定 cardId)的 character_book —— 给那些不支持全局世界书的客户端兜底。同内容已存在会跳过,所以重复调用安全。dryRun:true 只回预览。默认不合并:先导出独立文件、在客户端里绑全局世界书,才是零重复的做法。",
|
|
2231
|
+
parameters: {
|
|
2232
|
+
type:"object",
|
|
2233
|
+
properties:{
|
|
2234
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
2235
|
+
cardId:{ type:"string", description:"可选:默认用当前草稿的来源卡" },
|
|
2236
|
+
dryRun:{ type:"boolean", description:"true = 只回会加几条,不改卡" },
|
|
2237
|
+
},
|
|
2238
|
+
additionalProperties:false
|
|
2239
|
+
},
|
|
2240
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2241
|
+
async execute(args, exec){
|
|
2242
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
2243
|
+
if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
2244
|
+
message:"当前卡还没入库或没加入分组,没有可回填的共享世界书。" };
|
|
2245
|
+
const k = keyFor(ctx, exec);
|
|
2246
|
+
const origin = readOriginSync(k);
|
|
2247
|
+
const cardId = args.cardId ? String(args.cardId) : ((origin && origin.libraryId) ? String(origin.libraryId) : "");
|
|
2248
|
+
const res = await applySharedBook(r.group.id, cardId, args.dryRun === true);
|
|
2249
|
+
if(!res.ok) return { ok:false, needCard: res.needCard === true, notFound: res.notFound === true, error: String(res.error || "") };
|
|
2250
|
+
if(res.dryRun){
|
|
2251
|
+
return { ok:true, dryRun:true, cardId: res.cardId, wouldAdd: res.wouldAdd, bookEntries: res.bookEntries, cardEntries: res.cardEntries,
|
|
2252
|
+
message: "预览:这张卡会新增 " + res.wouldAdd + " 条来自共享世界书的条目(现有 " + res.cardEntries + " 条)。" };
|
|
2253
|
+
}
|
|
2254
|
+
return { ok:true, cardId: res.cardId, added: res.added, changed: res.changed === true, loreCount: res.loreCount,
|
|
2255
|
+
message: res.added
|
|
2256
|
+
? "已把这本共享世界书的 " + res.added + " 条合并进这张卡(现在 " + res.loreCount + " 条)。这只改了**库里的卡**;若你正在编辑草稿,记得重新载入或让用户在工坊里载入这张卡。"
|
|
2257
|
+
: "这张卡已经包含共享世界书里的全部条目,没有需要补充的。" };
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
|
|
1798
2261
|
// 【导出干跑】补上「五维到底导不导出」这个此前没人能回答的问题
|
|
1799
2262
|
tool({
|
|
1800
2263
|
name: "cc_preview_export",
|
|
@@ -1893,8 +2356,296 @@ function apply(ctx){
|
|
|
1893
2356
|
});
|
|
1894
2357
|
}
|
|
1895
2358
|
|
|
2359
|
+
// ——————————————————————————————————————————————————————————————
|
|
2360
|
+
// 组级共享世界书(0.9.0)—— 与 lib/index.js **同构实现**(同样的常量、纯函数、落盘规则)。
|
|
2361
|
+
// 理由见宿主半那段注释:群聊卡的世界设定在每张成员卡里逐字重复(实测 4 卡 20 条里 5 条相同),
|
|
2362
|
+
// CCv3 规范允许 Lorebook 单独导出(`{ spec:"lorebook_v3", data }`)且 character_book 本就该
|
|
2363
|
+
// 与全局 lorebook 叠加 —— 所以把共享设定提到组级独立文件,成员卡里只留角色私有条目。
|
|
2364
|
+
// ——————————————————————————————————————————————————————————————
|
|
2365
|
+
const BOOK_SPEC = "lorebook_v3";
|
|
2366
|
+
const BOOK_MAX_ENTRIES = 200;
|
|
2367
|
+
|
|
2368
|
+
function bookDirs(){ return dataRoots("cc-books"); }
|
|
2369
|
+
function bookDir(){ return bookDirs()[0]; }
|
|
2370
|
+
function bookPath(groupId){ return join(bookDir(), safeGroupId(groupId) + ".json"); }
|
|
2371
|
+
async function ensureBookDir(){ try{ await mkdir(bookDir(), { recursive: true }); }catch{} }
|
|
2372
|
+
|
|
2373
|
+
/** 条目归一化:**只补缺、不裁剪**(CCv3 要求把不认识的字段也原样保存)。 */
|
|
2374
|
+
function makeBookEntry(e){
|
|
2375
|
+
const src = (e && typeof e === "object") ? e : {};
|
|
2376
|
+
const out = Object.assign({}, src);
|
|
2377
|
+
out.keys = Array.isArray(src.keys) ? src.keys.map(String) : [];
|
|
2378
|
+
out.content = String(src.content == null ? "" : src.content);
|
|
2379
|
+
out.enabled = src.enabled !== false;
|
|
2380
|
+
out.insertion_order = Number.isFinite(Number(src.insertion_order)) ? Number(src.insertion_order) : 0;
|
|
2381
|
+
out.constant = src.constant === true;
|
|
2382
|
+
out.use_regex = src.use_regex === true;
|
|
2383
|
+
if(typeof out.name !== "string") out.name = out.name == null ? "" : String(out.name);
|
|
2384
|
+
if(!out.extensions || typeof out.extensions !== "object" || Array.isArray(out.extensions)) out.extensions = {};
|
|
2385
|
+
return out;
|
|
2386
|
+
}
|
|
2387
|
+
function bookCcOf(obj){
|
|
2388
|
+
const data = (obj && obj.data && typeof obj.data === "object") ? obj.data : {};
|
|
2389
|
+
const ext = (data.extensions && typeof data.extensions === "object") ? data.extensions : {};
|
|
2390
|
+
return (ext.cc && typeof ext.cc === "object") ? ext.cc : {};
|
|
2391
|
+
}
|
|
2392
|
+
function bookRevOf(obj){ const n = Number(bookCcOf(obj).rev); return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; }
|
|
2393
|
+
function normalizeBook(obj, groupId){
|
|
2394
|
+
const src = (obj && typeof obj === "object") ? obj : {};
|
|
2395
|
+
const raw = (src.data && typeof src.data === "object") ? src.data : (Array.isArray(src.entries) ? src : {});
|
|
2396
|
+
const cc = bookCcOf(src);
|
|
2397
|
+
const data = Object.assign({}, raw, {
|
|
2398
|
+
name: String(raw.name || "").slice(0, 80),
|
|
2399
|
+
description: String(raw.description || "").slice(0, 300),
|
|
2400
|
+
scan_depth: Math.max(1, Math.round(Number(raw.scan_depth) || 4)),
|
|
2401
|
+
token_budget: Math.max(100, Math.round(Number(raw.token_budget) || 2000)),
|
|
2402
|
+
recursive_scanning: raw.recursive_scanning === true,
|
|
2403
|
+
entries: (Array.isArray(raw.entries) ? raw.entries : []).slice(0, BOOK_MAX_ENTRIES).map(makeBookEntry),
|
|
2404
|
+
});
|
|
2405
|
+
const ext = Object.assign({}, (raw.extensions && typeof raw.extensions === "object") ? raw.extensions : {});
|
|
2406
|
+
ext.cc = Object.assign({}, (ext.cc && typeof ext.cc === "object") ? ext.cc : {}, {
|
|
2407
|
+
groupId: safeGroupId(groupId || cc.groupId),
|
|
2408
|
+
rev: Math.max(0, Math.round(Number(cc.rev) || 0)),
|
|
2409
|
+
updatedAt: Number(cc.updatedAt) || nowSec(),
|
|
2410
|
+
});
|
|
2411
|
+
data.extensions = ext;
|
|
2412
|
+
return Object.assign({}, src, { spec: BOOK_SPEC, data });
|
|
2413
|
+
}
|
|
2414
|
+
async function readBookRaw(groupId){
|
|
2415
|
+
const file = safeGroupId(groupId) + ".json";
|
|
2416
|
+
for(const dir of bookDirs()){
|
|
2417
|
+
try{
|
|
2418
|
+
const raw = await readFile(join(dir, file), "utf8");
|
|
2419
|
+
const obj = JSON.parse(raw);
|
|
2420
|
+
if(obj && typeof obj === "object") return { raw, obj };
|
|
2421
|
+
}catch{}
|
|
2422
|
+
}
|
|
2423
|
+
return null;
|
|
2424
|
+
}
|
|
2425
|
+
async function writeBook(groupId, book){
|
|
2426
|
+
await ensureBookDir();
|
|
2427
|
+
const gid = safeGroupId(groupId);
|
|
2428
|
+
const prev = await readBookRaw(gid);
|
|
2429
|
+
const cur = normalizeBook(book, gid);
|
|
2430
|
+
cur.data.extensions.cc.rev = (prev ? bookRevOf(prev.obj) : 0) + 1;
|
|
2431
|
+
cur.data.extensions.cc.updatedAt = nowSec();
|
|
2432
|
+
await writeFile(bookPath(gid), JSON.stringify(cur, null, 2), "utf8");
|
|
2433
|
+
return cur;
|
|
2434
|
+
}
|
|
2435
|
+
async function deleteBook(groupId){
|
|
2436
|
+
const gid = safeGroupId(groupId);
|
|
2437
|
+
if(!gid) return false;
|
|
2438
|
+
let removed = 0;
|
|
2439
|
+
for(const dir of bookDirs()){
|
|
2440
|
+
try{ await unlink(join(dir, gid + ".json")); removed += 1; }
|
|
2441
|
+
catch(e){ if(e && e.code !== "ENOENT") throw e; }
|
|
2442
|
+
}
|
|
2443
|
+
return removed > 0;
|
|
2444
|
+
}
|
|
2445
|
+
/** 内容签名(折叠空白 + djb2 + 长度):只用来**筛**,真正搬运/删除前一律再逐字比对内容。 */
|
|
2446
|
+
function contentKeyOf(entry){
|
|
2447
|
+
const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
|
|
2448
|
+
let h = 5381;
|
|
2449
|
+
for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
2450
|
+
return "c" + h.toString(36) + "-" + s.length;
|
|
2451
|
+
}
|
|
2452
|
+
async function bookCardsFor(ids){
|
|
2453
|
+
const out = [];
|
|
2454
|
+
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
2455
|
+
try{
|
|
2456
|
+
const obj = JSON.parse((await readLibraryRaw(id)).raw);
|
|
2457
|
+
const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
|
|
2458
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
2459
|
+
out.push({ id: String(id), name: String(d.name || (obj && obj.meta && obj.meta.name) || ""), entries: entries.map(makeBookEntry) });
|
|
2460
|
+
}catch{}
|
|
2461
|
+
}
|
|
2462
|
+
return out;
|
|
2463
|
+
}
|
|
2464
|
+
function scanSharedEntries(cards){
|
|
2465
|
+
const list = Array.isArray(cards) ? cards : [];
|
|
2466
|
+
const seen = new Map();
|
|
2467
|
+
list.forEach(c => {
|
|
2468
|
+
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2469
|
+
if(!String((e && e.content) || "").trim()) return;
|
|
2470
|
+
const sig = contentKeyOf(e);
|
|
2471
|
+
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 });
|
|
2472
|
+
const g = seen.get(sig);
|
|
2473
|
+
if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
|
|
2474
|
+
if(String(e.name || "") !== g.name) g.nameMismatch = true;
|
|
2475
|
+
if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
|
|
2476
|
+
});
|
|
2477
|
+
});
|
|
2478
|
+
const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
|
|
2479
|
+
.sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
|
|
2480
|
+
.map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
|
|
2481
|
+
const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
|
|
2482
|
+
return { candidates, stats: { cards: list.length, totalEntries,
|
|
2483
|
+
duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
|
|
2484
|
+
savedChars: candidates.reduce((n, g) => n + g.savedChars, 0) } };
|
|
2485
|
+
}
|
|
2486
|
+
function mergeBookEntries(bookEntries, cardEntries){
|
|
2487
|
+
const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
|
|
2488
|
+
const added = [];
|
|
2489
|
+
(Array.isArray(bookEntries) ? bookEntries : []).forEach(e => {
|
|
2490
|
+
const sig = contentKeyOf(e);
|
|
2491
|
+
if(have.has(sig)) return;
|
|
2492
|
+
have.add(sig);
|
|
2493
|
+
added.push(makeBookEntry(e));
|
|
2494
|
+
});
|
|
2495
|
+
return { entries: (Array.isArray(cardEntries) ? cardEntries : []).map(makeBookEntry).concat(added), added };
|
|
2496
|
+
}
|
|
2497
|
+
function applyBookPromotion(cards, bookEntries, signatures){
|
|
2498
|
+
const want = new Set((Array.isArray(signatures) ? signatures : []).map(String));
|
|
2499
|
+
const book = (Array.isArray(bookEntries) ? bookEntries : []).map(makeBookEntry);
|
|
2500
|
+
const have = new Set(book.map(contentKeyOf));
|
|
2501
|
+
const promoted = [];
|
|
2502
|
+
const nextCards = (Array.isArray(cards) ? cards : []).map(c => {
|
|
2503
|
+
const kept = [];
|
|
2504
|
+
let removed = 0;
|
|
2505
|
+
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2506
|
+
const sig = contentKeyOf(e);
|
|
2507
|
+
if(want.has(sig)){
|
|
2508
|
+
if(!have.has(sig)){ have.add(sig); book.push(makeBookEntry(e)); promoted.push(sig); }
|
|
2509
|
+
removed += 1;
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
kept.push(makeBookEntry(e));
|
|
2513
|
+
});
|
|
2514
|
+
return { id: c.id, name: c.name, entries: kept, removed };
|
|
2515
|
+
});
|
|
2516
|
+
return { bookEntries: book, cards: nextCards, promoted, removedTotal: nextCards.reduce((n, c) => n + c.removed, 0) };
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// —— 0.9.0:三个编排动作(与宿主半 cc_patchBook / cc_promoteBook / cc_applyBook 端点行为等价)——
|
|
2520
|
+
|
|
2521
|
+
/** 条目摘要(返回体卫生:默认只回 name/keys/长度/前 80 字,`includeContent` 才给全文)。 */
|
|
2522
|
+
function bookEntryBrief(e, includeContent){
|
|
2523
|
+
const brief = { contentKey: contentKeyOf(e), name: String(e.name || ""), keys: (e.keys || []).map(String),
|
|
2524
|
+
constant: e.constant === true, enabled: e.enabled !== false, chars: String(e.content || "").length };
|
|
2525
|
+
if(includeContent) brief.content = String(e.content || "");
|
|
2526
|
+
else brief.preview = String(e.content || "").slice(0, 80).replace(/\s+/g, " ");
|
|
2527
|
+
return brief;
|
|
2528
|
+
}
|
|
2529
|
+
function bookBrief(book, includeContent){
|
|
2530
|
+
const d = book.data;
|
|
2531
|
+
return { spec: BOOK_SPEC, rev: bookRevOf(book), name: d.name, description: d.description,
|
|
2532
|
+
scan_depth: d.scan_depth, token_budget: d.token_budget, recursive_scanning: d.recursive_scanning,
|
|
2533
|
+
entries: d.entries.map(e => bookEntryBrief(e, includeContent)) };
|
|
2534
|
+
}
|
|
2535
|
+
/** 改共享世界书(书级 meta + 条目 ops)。返回 { ok, ... }。 */
|
|
2536
|
+
async function patchSharedBook(groupId, args){
|
|
2537
|
+
const gid = safeGroupId(groupId);
|
|
2538
|
+
if(!gid) return { ok: false, error: "需要 groupId" };
|
|
2539
|
+
const found = await readBookRaw(gid);
|
|
2540
|
+
const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({ data: { name: String(args.name || "共享设定") } }, gid);
|
|
2541
|
+
if(typeof args.expectedBookRev === "number" && args.expectedBookRev !== bookRevOf(cur)){
|
|
2542
|
+
return { ok: false, staleRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur),
|
|
2543
|
+
error: "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先 cc_get_shared_book 重新读一遍再改。" };
|
|
2544
|
+
}
|
|
2545
|
+
const applied = [], rejected = [];
|
|
2546
|
+
if(args.name !== undefined) cur.data.name = String(args.name).slice(0, 80);
|
|
2547
|
+
if(args.description !== undefined) cur.data.description = String(args.description).slice(0, 300);
|
|
2548
|
+
if(args.scan_depth !== undefined) cur.data.scan_depth = Math.max(1, Math.round(Number(args.scan_depth) || 4));
|
|
2549
|
+
if(args.token_budget !== undefined) cur.data.token_budget = Math.max(100, Math.round(Number(args.token_budget) || 2000));
|
|
2550
|
+
if(args.recursive_scanning !== undefined) cur.data.recursive_scanning = args.recursive_scanning === true;
|
|
2551
|
+
["name", "description", "scan_depth", "token_budget", "recursive_scanning"].forEach(k => { if(args[k] !== undefined) applied.push({ op: "setMeta", field: k }); });
|
|
2552
|
+
(Array.isArray(args.ops) ? args.ops : []).forEach(op => {
|
|
2553
|
+
const kind = String((op && op.op) || "");
|
|
2554
|
+
if(kind === "addEntry"){
|
|
2555
|
+
const e = makeBookEntry(op.entry);
|
|
2556
|
+
if(!e.content.trim()){ rejected.push({ op, error: "content 不能为空" }); return; }
|
|
2557
|
+
if(cur.data.entries.some(x => contentKeyOf(x) === contentKeyOf(e))){ rejected.push({ op, error: "同内容的条目已存在" }); return; }
|
|
2558
|
+
if(cur.data.entries.length >= BOOK_MAX_ENTRIES){ rejected.push({ op, error: "条目数已达上限 " + BOOK_MAX_ENTRIES }); return; }
|
|
2559
|
+
cur.data.entries.push(e);
|
|
2560
|
+
applied.push({ op: kind, key: contentKeyOf(e), name: e.name });
|
|
2561
|
+
} else if(kind === "updateEntry"){
|
|
2562
|
+
const idx = cur.data.entries.findIndex(x => contentKeyOf(x) === String(op.contentKey || ""));
|
|
2563
|
+
if(idx < 0){ rejected.push({ op, error: "找不到条目(contentKey 不匹配)" }); return; }
|
|
2564
|
+
cur.data.entries[idx] = makeBookEntry(Object.assign({}, cur.data.entries[idx], (op.patch && typeof op.patch === "object") ? op.patch : {}));
|
|
2565
|
+
applied.push({ op: kind, key: contentKeyOf(cur.data.entries[idx]) });
|
|
2566
|
+
} else if(kind === "removeEntry"){
|
|
2567
|
+
// 支持两种定位:`index`(工坊列表直接用它)或 `contentKey`(模型读到的签名)
|
|
2568
|
+
const byIndex = Number.isInteger(op.index) ? op.index : -1;
|
|
2569
|
+
if(byIndex >= 0 && byIndex < cur.data.entries.length){
|
|
2570
|
+
const gone = cur.data.entries.splice(byIndex, 1)[0];
|
|
2571
|
+
applied.push({ op: kind, index: byIndex, key: contentKeyOf(gone) });
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
const before = cur.data.entries.length;
|
|
2575
|
+
cur.data.entries = cur.data.entries.filter(x => contentKeyOf(x) !== String(op.contentKey || ""));
|
|
2576
|
+
if(cur.data.entries.length === before){ rejected.push({ op, error: "找不到条目(index / contentKey 都不匹配)" }); return; }
|
|
2577
|
+
applied.push({ op: kind, key: String(op.contentKey) });
|
|
2578
|
+
} else rejected.push({ op, error: "未知 op:" + kind });
|
|
2579
|
+
});
|
|
2580
|
+
if(!applied.length) return { ok: true, book: cur, rev: bookRevOf(cur), applied, rejected, changed: false };
|
|
2581
|
+
const saved = await writeBook(gid, cur);
|
|
2582
|
+
return { ok: true, book: saved, rev: bookRevOf(saved), applied, rejected, changed: true };
|
|
2583
|
+
}
|
|
2584
|
+
/** 提升去重:把成员卡里重复的条目搬进共享书,并从各卡删掉(默认 dryRun,`apply:true` 才动手)。 */
|
|
2585
|
+
async function promoteSharedBook(groupId, args){
|
|
2586
|
+
const gid = safeGroupId(groupId);
|
|
2587
|
+
if(!gid) return { ok: false, error: "需要 groupId" };
|
|
2588
|
+
const found = await readBookRaw(gid);
|
|
2589
|
+
const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({}, gid);
|
|
2590
|
+
const cards = await bookCardsFor(await groupMemberIds(gid));
|
|
2591
|
+
const scan = scanSharedEntries(cards);
|
|
2592
|
+
const known = new Map(scan.candidates.map(c => [c.signature, c]));
|
|
2593
|
+
const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String) : scan.candidates.map(c => c.signature);
|
|
2594
|
+
const unknownKeys = requested.filter(s => !known.has(s));
|
|
2595
|
+
const use = requested.filter(s => known.has(s));
|
|
2596
|
+
const plan = applyBookPromotion(cards, cur.data.entries, use);
|
|
2597
|
+
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 });
|
|
2598
|
+
if(args.apply !== true){
|
|
2599
|
+
return { ok: true, dryRun: true, changed: false, wouldPromote: use.length, unknownKeys, removedTotal: plan.removedTotal,
|
|
2600
|
+
perCard: plan.cards.map(c => ({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length })),
|
|
2601
|
+
candidates: scan.candidates.map(summary), stats: scan.stats, rev: bookRevOf(cur) };
|
|
2602
|
+
}
|
|
2603
|
+
const saved = await writeBook(gid, Object.assign({}, cur, { data: Object.assign({}, cur.data, { entries: plan.bookEntries }) }));
|
|
2604
|
+
const touched = [];
|
|
2605
|
+
for(const c of plan.cards){
|
|
2606
|
+
if(!c.removed) continue;
|
|
2607
|
+
try{
|
|
2608
|
+
const rawOne = await readLibraryRaw(c.id);
|
|
2609
|
+
const obj = JSON.parse(rawOne.raw);
|
|
2610
|
+
obj.draft.data.character_book = Object.assign({}, obj.draft.data.character_book, { entries: c.entries });
|
|
2611
|
+
if(obj.meta){ obj.meta.loreCount = c.entries.length; obj.meta.updatedAt = nowSec(); } // 只动这两个字段,其余 meta 原样保留(B4 的教训)
|
|
2612
|
+
await writeFile(rawOne.path, JSON.stringify(obj, null, 2), "utf8");
|
|
2613
|
+
touched.push({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length });
|
|
2614
|
+
}catch(e){ touched.push({ id: c.id, name: c.name, error: String(e && e.message || e) }); }
|
|
2615
|
+
}
|
|
2616
|
+
return { ok: true, dryRun: false, changed: true, promoted: plan.promoted.length, removedTotal: plan.removedTotal, unknownKeys,
|
|
2617
|
+
bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched, candidates: scan.candidates.map(summary), stats: scan.stats };
|
|
2618
|
+
}
|
|
2619
|
+
/** 回填:把共享书条目合并进某张卡(同内容已存在则跳过)—— 给不支持全局世界书的客户端兜底。 */
|
|
2620
|
+
async function applySharedBook(groupId, cardId, dryRun){
|
|
2621
|
+
const gid = safeGroupId(groupId);
|
|
2622
|
+
if(!gid) return { ok: false, error: "需要 groupId" };
|
|
2623
|
+
const found = await readBookRaw(gid);
|
|
2624
|
+
if(!found) return { ok: false, notFound: true, error: "这个分组还没有共享世界书" };
|
|
2625
|
+
const cur = normalizeBook(found.obj, gid);
|
|
2626
|
+
if(!cardId) return { ok: false, needCard: true, error: "没有指定 cardId,当前草稿也没有来源卡(先保存到侧边栏)" };
|
|
2627
|
+
const rawOne = await readLibraryRaw(cardId);
|
|
2628
|
+
const obj = JSON.parse(rawOne.raw);
|
|
2629
|
+
const cardEntries = (obj.draft && obj.draft.data && obj.draft.data.character_book && Array.isArray(obj.draft.data.character_book.entries))
|
|
2630
|
+
? obj.draft.data.character_book.entries : [];
|
|
2631
|
+
const merged = mergeBookEntries(cur.data.entries, cardEntries);
|
|
2632
|
+
if(dryRun) return { ok: true, dryRun: true, cardId, wouldAdd: merged.added.length, bookEntries: cur.data.entries.length, cardEntries: cardEntries.length };
|
|
2633
|
+
if(!merged.added.length) return { ok: true, cardId, added: 0, changed: false, loreCount: cardEntries.length };
|
|
2634
|
+
obj.draft.data.character_book = Object.assign({}, obj.draft.data.character_book, { entries: merged.entries });
|
|
2635
|
+
if(obj.meta){ obj.meta.loreCount = merged.entries.length; obj.meta.updatedAt = nowSec(); }
|
|
2636
|
+
await writeFile(rawOne.path, JSON.stringify(obj, null, 2), "utf8");
|
|
2637
|
+
return { ok: true, cardId, added: merged.added.length, changed: true, loreCount: merged.entries.length };
|
|
2638
|
+
}
|
|
2639
|
+
|
|
1896
2640
|
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
1897
2641
|
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|
|
1898
2642
|
groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
1899
2643
|
renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, groupMemberIds, libraryCardsFor,
|
|
2644
|
+
// 0.8.0 分组管理:出边口径(渲染与计数共用)、建组查重、meta 就地写 —— 都与宿主半同构,测试直接比对
|
|
2645
|
+
outgoingEdgesOf, findGroupByName, patchLibraryMeta, listLibraryEntries, saveLibraryEntry,
|
|
2646
|
+
groupSummaries, assignCardToGroup, deleteGroupWithMembers,
|
|
2647
|
+
// 0.9.0 组级共享世界书:落盘 + 纯函数 + 三个编排动作(与宿主半同构,测试交叉比对行为)
|
|
2648
|
+
BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
|
|
2649
|
+
contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
|
|
2650
|
+
bookEntryBrief, bookBrief, patchSharedBook, promoteSharedBook, applySharedBook,
|
|
1900
2651
|
REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
|