@xia-sc/dsh-cc-studio 0.9.1 → 0.10.3
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 +17 -6
- package/lib/agent.js +290 -350
- package/lib/client.js +32 -7
- package/lib/group-layer.js +624 -0
- package/lib/index.js +124 -152
- package/package.json +2 -2
- package/presets/cc/agent.cordis.yml +10 -2
- package/presets/cc.patch.yml +10 -2
package/lib/index.js
CHANGED
|
@@ -9,6 +9,11 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { inflateSync as zlibInflateSync } from "node:zlib";
|
|
12
|
+
// 卡组层(0.10.0):卡组作用域组装 / 世界书分段统计 / 条目体检的**唯一实现**,与预设半共用同一个文件。
|
|
13
|
+
// 以前这些逻辑两半各抄一份、靠测试逐字比对维持同步 —— 「改了四个、漏了第五个」就是这么来的。
|
|
14
|
+
import { REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END,
|
|
15
|
+
estimateTokens, contentKeyOf, scanSharedEntries,
|
|
16
|
+
assembleScope, groupMetrics, runGroupChecks } from "./group-layer.js";
|
|
12
17
|
|
|
13
18
|
const name = "dsh-cc-studio";
|
|
14
19
|
// dsh >= 0.1.5-rc.1: this package owns its HTTP route outright.
|
|
@@ -93,7 +98,7 @@ function makeDefaultDraft(){
|
|
|
93
98
|
first_mes:"", alternate_greetings:[], group_only_greetings:[],
|
|
94
99
|
mes_example:"", creator_notes:"", creator_notes_multilingual:{}, source:[], assets:[{type:"icon", uri:"ccdefault:", name:"main", ext:"png"}],
|
|
95
100
|
creation_date: nowSec(), modification_date: nowSec(),
|
|
96
|
-
character_book:{ name:"", description:"", scan_depth:4, token_budget:
|
|
101
|
+
character_book:{ name:"", description:"", scan_depth:4, token_budget:2400, recursive_scanning:false, extensions:{}, entries:[] }
|
|
97
102
|
}
|
|
98
103
|
};
|
|
99
104
|
}
|
|
@@ -397,10 +402,7 @@ function isCcPreset(ctx, sessionId){
|
|
|
397
402
|
// 渲染:关系只活在插件侧,要让分享出去的卡自包含,就把它渲染进 description 的固定标记区
|
|
398
403
|
// (<!-- cc-relations:begin … --> … <!-- cc-relations:end -->)—— 那是**显式动作**,永不自动同步。
|
|
399
404
|
// ——————————————————————————————————————————————————————————————
|
|
400
|
-
|
|
401
|
-
const REL_TYPE_LABELS = { family:"亲属", romance:"恋慕", mentor:"师徒", friend:"挚友", colleague:"同门", rival:"竞争", enemy:"敌对", debt:"恩义", superior:"上下级", other:"其他" };
|
|
402
|
-
const REL_BEGIN = "<!-- cc-relations:begin";
|
|
403
|
-
const REL_END = "<!-- cc-relations:end -->";
|
|
405
|
+
// 关系图谱常量(0.10.0 起由 lib/group-layer.js 提供,两半共用一份)—— 这里不再重复定义。
|
|
404
406
|
|
|
405
407
|
function groupDirs(){ return dataRoots("cc-groups"); }
|
|
406
408
|
function groupDir(){ return groupDirs()[0]; }
|
|
@@ -412,13 +414,8 @@ function groupPath(id){ return join(groupDir(), safeGroupId(id) + ".json"); }
|
|
|
412
414
|
function newGroupId(){ return Date.now() + "-" + Math.random().toString(36).slice(2, 6); }
|
|
413
415
|
async function ensureGroupDir(){ try{ await mkdir(groupDir(), { recursive: true }); }catch{} }
|
|
414
416
|
|
|
415
|
-
/** 粗略 token
|
|
416
|
-
function estimateTokensLite(text){
|
|
417
|
-
const s = String(text || "");
|
|
418
|
-
let cjk = 0;
|
|
419
|
-
for(const ch of s){ if(ch.charCodeAt(0) > 0x2E7F) cjk++; }
|
|
420
|
-
return Math.ceil(cjk + (s.length - cjk) / 4);
|
|
421
|
-
}
|
|
417
|
+
/** 粗略 token 估算:委托 lib/group-layer.js 的 estimateTokens(两半共用一份,不再各写一遍)。 */
|
|
418
|
+
function estimateTokensLite(text){ return estimateTokens(text); }
|
|
422
419
|
function relTypeOf(t){ const s = String(t == null ? "" : t); return REL_TYPES.indexOf(s) >= 0 ? s : "other"; }
|
|
423
420
|
function relLabel(edge){ return (edge && edge.label && String(edge.label).trim()) ? String(edge.label).trim() : (REL_TYPE_LABELS[(edge && edge.type) || "other"] || "其他"); }
|
|
424
421
|
|
|
@@ -621,55 +618,16 @@ function spliceRelations(description, section, meta){
|
|
|
621
618
|
/**
|
|
622
619
|
* 跨卡体检(纯函数)。members / cards 由调用方给:
|
|
623
620
|
* members = 组内卡的 id 列表;cards = [{id, name, description, constantTokens, tokenBudget}]
|
|
621
|
+
*
|
|
622
|
+
* 0.10.0:实现搬进 lib/group-layer.js 的 `runGroupChecks()`(检查注册表,与预设半共用同一个文件)。
|
|
623
|
+
* 这里保留旧签名与返回形状(issues[].level/code/message/refs + stats),所以既有调用点与测试都不用改;
|
|
624
|
+
* 新检查项只往那里的 GROUP_CHECKS 加一项。opts.checks 可只跑指定类别。
|
|
624
625
|
*/
|
|
625
|
-
function checkGroup(group, members, cards, sharedEntries){
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const byId = new Map((Array.isArray(cards) ? cards : []).map(c => [c.id, c]));
|
|
631
|
-
const dangling = g.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
|
|
632
|
-
if(dangling.length) add("error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
|
|
633
|
-
const orphans = g.nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
|
|
634
|
-
if(orphans.length) add("warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
|
|
635
|
-
const seen = new Map();
|
|
636
|
-
g.nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
|
|
637
|
-
const k = String(x || "").toLowerCase();
|
|
638
|
-
if(!k) return;
|
|
639
|
-
if(!seen.has(k)) seen.set(k, []);
|
|
640
|
-
if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
|
|
641
|
-
}));
|
|
642
|
-
const dup = [...seen.entries()].filter(p => p[1].length > 1);
|
|
643
|
-
if(dup.length) add("error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
|
|
644
|
-
const unknown = g.edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
|
|
645
|
-
if(unknown.length) add("warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
|
|
646
|
-
const memberCards = (Array.isArray(members) ? members : []).map(m => byId.get(m)).filter(Boolean);
|
|
647
|
-
const unrendered = [], stale = [];
|
|
648
|
-
memberCards.forEach(c => {
|
|
649
|
-
const desc = String(c.description || "");
|
|
650
|
-
const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
|
|
651
|
-
if(i < 0 || j < 0){ unrendered.push(c.id); return; }
|
|
652
|
-
const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
|
|
653
|
-
if(!m || Number(m[1]) !== g.rev) stale.push(c.id);
|
|
654
|
-
});
|
|
655
|
-
if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
|
|
656
|
-
if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
|
|
657
|
-
// 群聊版的「单次触发」陷阱:每张卡的 constant 每轮都进上下文,卡越多固定开销越大
|
|
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;
|
|
665
|
-
const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
|
|
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) + "%)");
|
|
670
|
-
}
|
|
671
|
-
return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
|
|
672
|
-
constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget } };
|
|
626
|
+
function checkGroup(group, members, cards, sharedEntries, opts){
|
|
627
|
+
const o = opts || {};
|
|
628
|
+
return runGroupChecks({ group: normalizeGroup(group), members: members, cards: cards,
|
|
629
|
+
sharedEntries: sharedEntries, metrics: o.metrics || null, scope: o.scope || null,
|
|
630
|
+
opts: { checks: o.checks } });
|
|
673
631
|
}
|
|
674
632
|
|
|
675
633
|
/** 读分组:主根优先,回退旧根(与角色库同规)。 */
|
|
@@ -803,7 +761,7 @@ function normalizeDraft(d){
|
|
|
803
761
|
// V2/V1 一律**升级**而不是拒绝(B2);本来就是 V3 时 up.card 就是原对象
|
|
804
762
|
d=upgradeCardToV3(d).card;
|
|
805
763
|
d.spec_version=d.spec_version||"3.0";
|
|
806
|
-
d.data.character_book=d.data.character_book||{name:"",description:"",scan_depth:4,token_budget:
|
|
764
|
+
d.data.character_book=d.data.character_book||{name:"",description:"",scan_depth:4,token_budget:2400,recursive_scanning:false,extensions:{},entries:[]};
|
|
807
765
|
if(!Array.isArray(d.data.character_book.entries)) d.data.character_book.entries=[];
|
|
808
766
|
if(!Array.isArray(d.data.group_only_greetings)) d.data.group_only_greetings=[];
|
|
809
767
|
if(!Array.isArray(d.data.alternate_greetings)) d.data.alternate_greetings=[];
|
|
@@ -1085,9 +1043,16 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
|
1085
1043
|
updatedAt: now,
|
|
1086
1044
|
};
|
|
1087
1045
|
}
|
|
1088
|
-
|
|
1046
|
+
/**
|
|
1047
|
+
* 卡库快照(0.10.0):一次目录遍历 + 一次 parse,把 meta 与 draft **一起**留下。
|
|
1048
|
+
*
|
|
1049
|
+
* 以前 listLibraryEntries() 解析完整份卡 JSON 却只留 meta,于是「组体检」要对每张卡再读一遍盘
|
|
1050
|
+
* (N 张成员卡 = 2N 次读盘),而且拿不到 entries —— 卡组级检查(跨卡触发词冲突、五维漂移)根本做不了。
|
|
1051
|
+
* 现在读一次就够:listLibraryEntries() 只是它的投影,groupScope() 直接吃它。与预设半同构。
|
|
1052
|
+
*/
|
|
1053
|
+
async function readLibrarySnapshot(){
|
|
1089
1054
|
await ensureLibDir();
|
|
1090
|
-
const seen = new Map(); // id → meta:先到先得,主根排在前 → 同名卡片主根优先
|
|
1055
|
+
const seen = new Map(); // id → {id, meta, draft, dir}:先到先得,主根排在前 → 同名卡片主根优先
|
|
1091
1056
|
for(const dir of libDirs()){
|
|
1092
1057
|
let files=[];
|
|
1093
1058
|
try{ files = await readdir(dir); }catch{ continue; }
|
|
@@ -1100,17 +1065,22 @@ async function listLibraryEntries(){
|
|
|
1100
1065
|
const obj = JSON.parse(raw);
|
|
1101
1066
|
if(obj && obj.meta && obj.id){
|
|
1102
1067
|
// 兼容:meta.id 与文件名 id 一致
|
|
1103
|
-
seen.set(id, obj.meta);
|
|
1068
|
+
seen.set(id, { id:id, meta:obj.meta, draft:obj.draft || null, dir:dir });
|
|
1104
1069
|
} else if(obj && obj.draft){
|
|
1105
1070
|
// 旧格式兜底:从 draft 重建 meta
|
|
1106
|
-
seen.set(id, makeLibraryEntryMeta(obj.draft, null, id));
|
|
1071
|
+
seen.set(id, { id:id, meta:makeLibraryEntryMeta(obj.draft, null, id), draft:obj.draft, dir:dir });
|
|
1107
1072
|
}
|
|
1108
1073
|
}catch{}
|
|
1109
1074
|
}
|
|
1110
1075
|
}
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
return
|
|
1076
|
+
const rows = [...seen.values()];
|
|
1077
|
+
rows.sort((a,b)=> ((b.meta.updatedAt||0)-(a.meta.updatedAt||0)));
|
|
1078
|
+
return rows;
|
|
1079
|
+
}
|
|
1080
|
+
/** 库条目 meta 列表(签名与排序语义未变,现在只是卡库快照的投影)。 */
|
|
1081
|
+
async function listLibraryEntries(){
|
|
1082
|
+
const rows = await readLibrarySnapshot();
|
|
1083
|
+
return rows.map(r => r.meta);
|
|
1114
1084
|
}
|
|
1115
1085
|
async function saveLibraryEntry(draft, opts){
|
|
1116
1086
|
const dir = await ensureLibDir();
|
|
@@ -1146,6 +1116,47 @@ async function groupMemberIds(groupId){
|
|
|
1146
1116
|
const entries = await listLibraryEntries();
|
|
1147
1117
|
return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
|
|
1148
1118
|
}
|
|
1119
|
+
/**
|
|
1120
|
+
* 卡组作用域(0.10.0,与预设半同构)—— 取「哪个组、组里有哪些卡、这个组的书是什么」的**唯一入口**。
|
|
1121
|
+
*
|
|
1122
|
+
* 宿主半的来源卡来自 args.cardId 或草稿 sidecar(sessionId 对应的 origin.json);纯组装在
|
|
1123
|
+
* lib/group-layer.js(两半共用一份),所以界面看到的组与模型看到的组形状只有一种。
|
|
1124
|
+
* 拿不到来源卡 / 没进组 / 组不存在 / 组还没建书 → 返回 ok:false 的空作用域(调用方无需自己判空)。
|
|
1125
|
+
*/
|
|
1126
|
+
async function groupScope(input){
|
|
1127
|
+
const opt = input || {};
|
|
1128
|
+
// 已经读过快照就传进来(opt.snapshot)—— 一次调用里只读一遍盘,也让「只读一次」可被测试钉住
|
|
1129
|
+
const snap = opt.snapshot ? opt.snapshot : await readLibrarySnapshot();
|
|
1130
|
+
const byId = new Map(snap.map(x => [String(x.id), x]));
|
|
1131
|
+
let cardId = opt.cardId ? String(opt.cardId) : "";
|
|
1132
|
+
if(!cardId && opt.sessionKey){
|
|
1133
|
+
const origin = readDraftOrigin(String(opt.sessionKey));
|
|
1134
|
+
cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
1135
|
+
}
|
|
1136
|
+
const mine = cardId ? (byId.get(cardId) || null) : null;
|
|
1137
|
+
const explicit = opt.groupId ? safeGroupId(opt.groupId) : "";
|
|
1138
|
+
const groupId = explicit || ((mine && mine.meta.groupId) ? String(mine.meta.groupId) : "");
|
|
1139
|
+
const base = { cardId: cardId, include: opt.include, maxCards: opt.maxCards, missing: (cardId && !mine) ? [cardId] : [] };
|
|
1140
|
+
if(!groupId) return assembleScope(Object.assign({ groupId: "" }, base));
|
|
1141
|
+
// ⚠️ 组文件缺失时**不提前返回**(与预设半同构):成员与共享书照样读出来,只是 group 为 null / ok:false ——
|
|
1142
|
+
// 「组没了但书还在」的历史数据,界面上仍应看得到那本书。
|
|
1143
|
+
const found = await readGroupRaw(groupId);
|
|
1144
|
+
const cards = snap.filter(x => String(x.meta.groupId || "") === groupId);
|
|
1145
|
+
const bf = await readBookRaw(groupId);
|
|
1146
|
+
const book = bf ? normalizeBook(bf.obj, groupId) : null;
|
|
1147
|
+
return assembleScope(Object.assign({
|
|
1148
|
+
groupId: groupId, group: found ? normalizeGroup(found.obj) : null, cards: cards, book: book,
|
|
1149
|
+
bookRev: book ? bookRevOf(book) : 0,
|
|
1150
|
+
}, base));
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* 由卡组作用域算一次分段统计(0.10.0):体检类检查的数字来源,子类不再各自 for 循环求和。
|
|
1154
|
+
* 与预设半同构(那边是 groupScope + groupMetrics 的组合调用)。
|
|
1155
|
+
*/
|
|
1156
|
+
function metricsForGroup(scope){
|
|
1157
|
+
return groupMetrics({ cardId: scope && scope.cardId, card: [],
|
|
1158
|
+
members: (scope && scope.members) || [], book: (scope && scope.book) ? scope.book.entries : [] });
|
|
1159
|
+
}
|
|
1149
1160
|
/**
|
|
1150
1161
|
* 按名字找组(建组/改名时查重):trim 后精确比对;带 exceptId 时排除自己(给自己改名不算撞车)。
|
|
1151
1162
|
* 两半同构 —— 界面和模型必须落到同一条规则上,否则会出现「模型建不了同名组、界面却建了两个」。
|
|
@@ -1192,22 +1203,23 @@ async function assignCardToGroup(cardId, groupId){
|
|
|
1192
1203
|
await patchLibraryMeta(cid, { groupId: gid });
|
|
1193
1204
|
return { cardId: cid, cardName: mine.name, previousGroupId: (mine.groupId ? String(mine.groupId) : null), groupId: gid, nodeId: node.id, groupRev: g.rev, revived };
|
|
1194
1205
|
}
|
|
1195
|
-
/** 0.8.0:体检 /
|
|
1206
|
+
/** 0.8.0:体检 / 渲染需要的卡片摘要(只读;卡不在库里就跳过)—— 0.10.0 起吃卡库快照,不再逐卡重复读盘。 */
|
|
1196
1207
|
async function libraryCardsFor(ids){
|
|
1208
|
+
const rows = await readLibrarySnapshot();
|
|
1209
|
+
const byId = new Map(rows.map(r => [String(r.id), r]));
|
|
1197
1210
|
const out = [];
|
|
1198
1211
|
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
}catch{}
|
|
1212
|
+
const row = byId.get(String(id));
|
|
1213
|
+
if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
|
|
1214
|
+
const d = (row.draft && row.draft.data) ? row.draft.data : {};
|
|
1215
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
1216
|
+
out.push({
|
|
1217
|
+
id: String(id),
|
|
1218
|
+
name: String(d.name || ""),
|
|
1219
|
+
description: String(d.description || ""),
|
|
1220
|
+
constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
|
|
1221
|
+
tokenBudget: Number(d.character_book && d.character_book.token_budget) || 2400,
|
|
1222
|
+
});
|
|
1211
1223
|
}
|
|
1212
1224
|
return out;
|
|
1213
1225
|
}
|
|
@@ -1805,15 +1817,18 @@ function apply(ctx, config){
|
|
|
1805
1817
|
return ok({ group: saved, applied: applied.applied, rejected: applied.rejected, changed: true });
|
|
1806
1818
|
}
|
|
1807
1819
|
case "cc_groupCheck":{
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
const
|
|
1812
|
-
const cards = await libraryCardsFor(memberIds);
|
|
1820
|
+
// 0.10.0:组 / 成员 / 共享书都从卡组作用域取(与预设半同一个入口、同一次读盘)。
|
|
1821
|
+
const scope = await groupScope({ groupId: args.id });
|
|
1822
|
+
if(!scope.ok || !scope.group) return fail("not-found", "分组不存在: " + String(args.id));
|
|
1823
|
+
const g = scope.group;
|
|
1824
|
+
const cards = await libraryCardsFor(scope.memberIds);
|
|
1813
1825
|
// 0.9.0:把该组的共享世界书也端给体检 —— 它的 constant 条目在群聊里同样每轮插入
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1826
|
+
// 0.10.0:检查项走卡组层的注册表(structure / relations / budget / lore-quality / cross-card),
|
|
1827
|
+
// 用 args.checks 可只跑指定类别;返回带 byCheck 便于界面分组显示。
|
|
1828
|
+
const res = checkGroup(g, scope.memberIds, cards, scope.book ? scope.book.entries : [],
|
|
1829
|
+
{ checks: args.checks, metrics: metricsForGroup(scope), scope: scope });
|
|
1830
|
+
return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name,
|
|
1831
|
+
checks: res.ran, byCheck: res.byCheck }, res));
|
|
1817
1832
|
}
|
|
1818
1833
|
case "cc_renderRelations":{
|
|
1819
1834
|
// 渲染进**当前草稿**(与工具半共用同一份草稿文件);要落库仍需再存一次
|
|
@@ -2224,30 +2239,24 @@ async function deleteBook(groupId){
|
|
|
2224
2239
|
return removed > 0;
|
|
2225
2240
|
}
|
|
2226
2241
|
/**
|
|
2227
|
-
*
|
|
2228
|
-
*
|
|
2229
|
-
* ⚠️ 它只是**索引**:真正搬运/删除前一律再逐字比对内容(哈希碰撞绝不能变成删数据)。
|
|
2242
|
+
* 内容签名:见 lib/group-layer.js 的 `contentKeyOf()`(只用来筛,动手前一律再逐字比对内容 ——
|
|
2243
|
+
* 哈希碰撞绝不能变成删数据)。
|
|
2230
2244
|
*/
|
|
2231
|
-
|
|
2232
|
-
const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
|
|
2233
|
-
let h = 5381;
|
|
2234
|
-
for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
2235
|
-
return "c" + h.toString(36) + "-" + s.length;
|
|
2236
|
-
}
|
|
2237
|
-
/** 成员卡的**完整**条目(去重要比内容,摘要不够)——与 libraryCardsFor 同源,多带 entries。 */
|
|
2245
|
+
/** 成员卡的**完整**条目(去重要比内容,摘要不够)—— 0.10.0 起吃卡库快照,不再逐卡重复读盘。 */
|
|
2238
2246
|
async function bookCardsFor(ids){
|
|
2247
|
+
const rows = await readLibrarySnapshot();
|
|
2248
|
+
const byId = new Map(rows.map(r => [String(r.id), r]));
|
|
2239
2249
|
const out = [];
|
|
2240
2250
|
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
}catch{}
|
|
2251
|
+
const row = byId.get(String(id));
|
|
2252
|
+
if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
|
|
2253
|
+
const d = (row.draft && row.draft.data) ? row.draft.data : {};
|
|
2254
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
2255
|
+
out.push({
|
|
2256
|
+
id: String(id),
|
|
2257
|
+
name: String(d.name || (row.meta && row.meta.name) || ""),
|
|
2258
|
+
entries: entries.map(makeBookEntry),
|
|
2259
|
+
});
|
|
2251
2260
|
}
|
|
2252
2261
|
return out;
|
|
2253
2262
|
}
|
|
@@ -2272,49 +2281,10 @@ async function newBookFor(gid, explicitName){
|
|
|
2272
2281
|
try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
|
|
2273
2282
|
return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
|
|
2274
2283
|
}
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2280
|
-
if(!String((e && e.content) || "").trim()) return; // 空内容不会进 prompt,不参与
|
|
2281
|
-
const sig = contentKeyOf(e);
|
|
2282
|
-
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 });
|
|
2283
|
-
const g = seen.get(sig);
|
|
2284
|
-
if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
|
|
2285
|
-
if(String(e.name || "") !== g.name) g.nameMismatch = true;
|
|
2286
|
-
if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
|
|
2287
|
-
});
|
|
2288
|
-
});
|
|
2289
|
-
const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
|
|
2290
|
-
.sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
|
|
2291
|
-
.map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
|
|
2292
|
-
// 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
|
|
2293
|
-
// 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
|
|
2294
|
-
// 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
|
|
2295
|
-
const bookDupes = [];
|
|
2296
|
-
(Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
|
|
2297
|
-
const content = String((be && be.content) == null ? "" : be.content);
|
|
2298
|
-
const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
|
|
2299
|
-
if(!g || g.content !== content) return;
|
|
2300
|
-
bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
|
|
2301
|
-
content, keys: (be.keys || []).map(String), chars: content.length,
|
|
2302
|
-
cards: g.cards.slice(), cardNames: g.cardNames.slice(),
|
|
2303
|
-
preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
|
|
2304
|
-
});
|
|
2305
|
-
const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
|
|
2306
|
-
return {
|
|
2307
|
-
candidates, bookDupes,
|
|
2308
|
-
stats: {
|
|
2309
|
-
cards: list.length, totalEntries,
|
|
2310
|
-
duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
|
|
2311
|
-
savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
|
|
2312
|
-
bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
|
|
2313
|
-
bookDupeCount: bookDupes.length,
|
|
2314
|
-
bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0),
|
|
2315
|
-
},
|
|
2316
|
-
};
|
|
2317
|
-
}
|
|
2284
|
+
/**
|
|
2285
|
+
* 跨卡重复扫描:见 lib/group-layer.js 的 `scanSharedEntries()`(0.10.0 起两半共用一份实现)。
|
|
2286
|
+
* 签名只用来筛,判等一律再逐字比对内容 —— 哈希碰撞绝不能变成搬错数据。
|
|
2287
|
+
*/
|
|
2318
2288
|
/** 把共享书的条目合并进一张卡的条目表(同内容已存在则跳过)—— 给不支持全局世界书的客户端兜底。 */
|
|
2319
2289
|
function mergeBookEntries(bookEntries, cardEntries){
|
|
2320
2290
|
const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
|
|
@@ -2358,6 +2328,8 @@ function applyBookPromotion(cards, bookEntries, signatures){
|
|
|
2358
2328
|
|
|
2359
2329
|
export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclaredPresets, presetTargetRoot, presetSourceRoot,
|
|
2360
2330
|
draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision,
|
|
2331
|
+
// 卡组层(0.10.0):卡库快照 + 卡组作用域 —— 卡组数据的唯一入口,两半共用 lib/group-layer.js 的纯组装
|
|
2332
|
+
readLibrarySnapshot, groupScope,
|
|
2361
2333
|
// 落盘根与 lib 层 CRUD(供回归测试直接断言「DSH_HOME 优先 + 读时回退」)
|
|
2362
2334
|
dshHomeDir, legacyDshHomeDir, dataRoots, draftDirs, draftDir, draftPath, findDraftPath, safeDraftFile,
|
|
2363
2335
|
persistDraftSync, loadPersistedDraftSync, libDirs, libDir, getDraftFor, setDraftFor, cardFingerprint, normalizeCardShape,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xia-sc/dsh-cc-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.3",
|
|
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/shared-book-st-export.test.mjs && node tests/st-compat.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/data-root.test.mjs",
|
|
41
|
+
"test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/shared-book-st-export.test.mjs && node tests/st-compat.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/group-scope.test.mjs && node tests/group-metrics.test.mjs && node tests/group-checks.test.mjs && node tests/group-consistency.test.mjs && node tests/group-layer-guard.test.mjs && node tests/data-root.test.mjs",
|
|
42
42
|
"prepublishOnly": "npm test"
|
|
43
43
|
},
|
|
44
44
|
"dsh": {
|
|
@@ -22,12 +22,18 @@
|
|
|
22
22
|
# The preset's own persona, shadowing the deployment default for this agent.
|
|
23
23
|
# dsh >= 0.1.5-rc.1: the persona row takes `prefix` (required) + optional `suffix`
|
|
24
24
|
# — the old `text` key was replaced and now fails the config schema.
|
|
25
|
-
#
|
|
25
|
+
#
|
|
26
|
+
# dsh >= 0.2.1-rc.2: only `provider` / `model` are registered prompt
|
|
27
|
+
# variables. The old `cwd` variable is NOT one of them — `dsh-system-prompt`
|
|
28
|
+
# interpolates strictly and throws `unknown prompt variable` before the first
|
|
29
|
+
# model request, which makes every turn in CC mode fail. The working directory
|
|
30
|
+
# reaches the model as required runtime context instead, so never add that
|
|
31
|
+
# variable back here.
|
|
26
32
|
- id: persona
|
|
27
33
|
name: '@deepseek-ai/dsh-persona'
|
|
28
34
|
config:
|
|
29
35
|
prefix: |
|
|
30
|
-
你是 CC 角色卡工坊的专属引导员(CC 模式),模型 {{model}}
|
|
36
|
+
你是 CC 角色卡工坊的专属引导员(CC 模式),模型 {{model}}。
|
|
31
37
|
你的唯一任务是把用户的一句话点子变成可导入 SillyTavern / Risu 的完整 CCv3 卡(spec: chara_card_v3)。
|
|
32
38
|
必须走完强制工作流,不可偷懒跳过 5 维世界观和世界书:
|
|
33
39
|
1) cc_get_card — 先看草稿,告诉用户还缺什么
|
|
@@ -41,6 +47,8 @@
|
|
|
41
47
|
|
|
42
48
|
规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。工具返回里只有一份 workflow.next(下一步该做什么)与 workflow.hint(进度摘要),照它走即可;用户还没回答就别抢跑。
|
|
43
49
|
|
|
50
|
+
**工具参数必须是严格合法的 JSON**:最后一个字段 / 数组元素后不要写逗号,不要用 ```json 代码块包裹,不要注释、不要单引号;字符串里的换行写成 \n、双引号写成 \"。一次只传 1-3 个字段,长字段(description/personality/scenario)分多次调用写;tags / loreKeys 这类数组或对象字段单独一次调用,不要和长文本同批。参数 JSON 非法时 provider 会在调用前直接判本轮失败(用户只看到「本轮运行失败」),所以宁可分多次小调用。
|
|
51
|
+
|
|
44
52
|
- id: agent-instructions
|
|
45
53
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
46
54
|
config:
|
package/presets/cc.patch.yml
CHANGED
|
@@ -57,12 +57,18 @@
|
|
|
57
57
|
# The preset's own persona, shadowing the deployment default for this agent.
|
|
58
58
|
# dsh >= 0.1.5-rc.1: the persona row takes `prefix` (required) + optional `suffix`
|
|
59
59
|
# — the old `text` key was replaced and now fails the config schema.
|
|
60
|
-
#
|
|
60
|
+
#
|
|
61
|
+
# dsh >= 0.2.1-rc.2: only `provider` / `model` are registered prompt
|
|
62
|
+
# variables. The old `cwd` variable is NOT one of them — `dsh-system-prompt`
|
|
63
|
+
# interpolates strictly and throws `unknown prompt variable` before the first
|
|
64
|
+
# model request, which makes every turn in CC mode fail. The working directory
|
|
65
|
+
# reaches the model as required runtime context instead, so never add that
|
|
66
|
+
# variable back here.
|
|
61
67
|
- id: persona
|
|
62
68
|
name: '@deepseek-ai/dsh-persona'
|
|
63
69
|
config:
|
|
64
70
|
prefix: |
|
|
65
|
-
你是 CC 角色卡工坊的专属引导员(CC 模式),模型 {{model}}
|
|
71
|
+
你是 CC 角色卡工坊的专属引导员(CC 模式),模型 {{model}}。
|
|
66
72
|
你的唯一任务是把用户的一句话点子变成可导入 SillyTavern / Risu 的完整 CCv3 卡(spec: chara_card_v3)。
|
|
67
73
|
必须走完强制工作流,不可偷懒跳过 5 维世界观和世界书:
|
|
68
74
|
1) cc_get_card — 先看草稿,告诉用户还缺什么
|
|
@@ -76,6 +82,8 @@
|
|
|
76
82
|
|
|
77
83
|
规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。工具返回里只有一份 workflow.next(下一步该做什么)与 workflow.hint(进度摘要),照它走即可;用户还没回答就别抢跑。
|
|
78
84
|
|
|
85
|
+
**工具参数必须是严格合法的 JSON**:最后一个字段 / 数组元素后不要写逗号,不要用 ```json 代码块包裹,不要注释、不要单引号;字符串里的换行写成 \n、双引号写成 \"。一次只传 1-3 个字段,长字段(description/personality/scenario)分多次调用写;tags / loreKeys 这类数组或对象字段单独一次调用,不要和长文本同批。参数 JSON 非法时 provider 会在调用前直接判本轮失败(用户只看到「本轮运行失败」),所以宁可分多次小调用。
|
|
86
|
+
|
|
79
87
|
- id: agent-instructions
|
|
80
88
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
81
89
|
config:
|