@xia-sc/dsh-cc-studio 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -384,6 +384,327 @@ function isCcPreset(ctx, sessionId){
384
384
  }
385
385
 
386
386
  // —— 已存角色侧边栏:落盘到 <主根>/cc-library/<id>.json(读盘回退 0.3.6 之前的 homedir()/.dsh)——
387
+ // ——————————————————————————————————————————————————————————————
388
+ // 人物关系图谱(0.8.0):分组文件 + 三维关系(方向 × 类型 × 强度)
389
+ //
390
+ // 落盘 <主根>/cc-groups/<id>.json;「卡属于哪个组」记在库条目 meta.groupId(**单一真源**),
391
+ // 分组文件**不存 members** —— 成员由 listLibraryEntries() 过滤得出,删卡/移组天然一致,不会双写漂移。
392
+ //
393
+ // 纯函数(normalizeGroup / validateGroupOp / applyGroupOps / renderRelationsSection / spliceRelations /
394
+ // checkGroup / estimateTokensLite)在 lib/index.js 与 lib/agent.js 各有一份**同构实现**
395
+ // (与 dataRoots / safeDraftFile 同模式);tests/relations.test.mjs 会逐字比对两半的渲染输出。
396
+ //
397
+ // 渲染:关系只活在插件侧,要让分享出去的卡自包含,就把它渲染进 description 的固定标记区
398
+ // (<!-- cc-relations:begin … --> … <!-- cc-relations:end -->)—— 那是**显式动作**,永不自动同步。
399
+ // ——————————————————————————————————————————————————————————————
400
+ const REL_TYPES = ["family","romance","mentor","friend","colleague","rival","enemy","debt","superior","other"];
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 -->";
404
+
405
+ function groupDirs(){ return dataRoots("cc-groups"); }
406
+ function groupDir(){ return groupDirs()[0]; }
407
+ /** 分组 id 只允许 [A-Za-z0-9_-]:它直接进文件名,必须挡住路径穿越。 */
408
+ function safeGroupId(id){
409
+ return String(id == null ? "" : id).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
410
+ }
411
+ function groupPath(id){ return join(groupDir(), safeGroupId(id) + ".json"); }
412
+ function newGroupId(){ return Date.now() + "-" + Math.random().toString(36).slice(2, 6); }
413
+ async function ensureGroupDir(){ try{ await mkdir(groupDir(), { recursive: true }); }catch{} }
414
+
415
+ /** 粗略 token 估算(与 lib/agent.js 同构):CJK 一字≈1 token,其余 4 字符≈1 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
+ }
422
+ function relTypeOf(t){ const s = String(t == null ? "" : t); return REL_TYPES.indexOf(s) >= 0 ? s : "other"; }
423
+ function relLabel(edge){ return (edge && edge.label && String(edge.label).trim()) ? String(edge.label).trim() : (REL_TYPE_LABELS[(edge && edge.type) || "other"] || "其他"); }
424
+
425
+ /**
426
+ * 结构兜底(沿用 0.7.1 B1 的教训:外部改坏的文件不能让 undefined 漏进返回值)。
427
+ * ⚠️ **不丢弃端点不存在的边** —— 那是用户数据,破坏它比报错更糟;体检会报 dangling-edge、
428
+ * 渲染时会自动跳过。
429
+ */
430
+ function normalizeGroup(g){
431
+ const src = (g && typeof g === "object") ? g : {};
432
+ const nodes = Array.isArray(src.nodes) ? src.nodes.filter(n => n && typeof n === "object" && n.id).map(n => ({
433
+ id: String(n.id),
434
+ kind: (n.kind === "card") ? "card" : "npc",
435
+ cardId: (typeof n.cardId === "string" && n.cardId) ? n.cardId : null,
436
+ name: String(n.name || "").slice(0, 60),
437
+ aliases: Array.isArray(n.aliases) ? n.aliases.map(String).map(s => s.trim()).filter(Boolean).slice(0, 8) : [],
438
+ orphanOf: (typeof n.orphanOf === "string" && n.orphanOf) ? n.orphanOf : null,
439
+ })) : [];
440
+ const edges = Array.isArray(src.edges) ? src.edges.filter(e => e && typeof e === "object" && e.id).map(e => ({
441
+ id: String(e.id),
442
+ from: String(e.from || ""),
443
+ to: String(e.to || ""),
444
+ type: relTypeOf(e.type),
445
+ label: String(e.label || "").slice(0, 24),
446
+ strength: Math.max(1, Math.min(5, Math.round(Number(e.strength) || 3))),
447
+ direction: (e.direction === "mutual") ? "mutual" : "one-way",
448
+ note: String(e.note || "").slice(0, 200),
449
+ updatedAt: Number(e.updatedAt) || nowSec(),
450
+ })) : [];
451
+ return {
452
+ id: safeGroupId(src.id),
453
+ name: String(src.name || "").slice(0, 60) || "未命名分组",
454
+ note: String(src.note || "").slice(0, 200),
455
+ nodes, edges,
456
+ nextNodeSeq: Math.max(1, Math.round(Number(src.nextNodeSeq) || (nodes.length + 1))),
457
+ nextEdgeSeq: Math.max(1, Math.round(Number(src.nextEdgeSeq) || (edges.length + 1))),
458
+ rev: Math.max(0, Math.round(Number(src.rev) || 0)),
459
+ updatedAt: Number(src.updatedAt) || nowSec(),
460
+ };
461
+ }
462
+ /** 组内名字/别名是否与别的节点撞车(撞了渲染就会指向同一个人)。 */
463
+ function dupNodeName(g, name, aliases, exceptId){
464
+ const wanted = [String(name || "").trim()].concat(Array.isArray(aliases) ? aliases.map(s => String(s).trim()) : [])
465
+ .filter(Boolean).map(s => s.toLowerCase());
466
+ if(!wanted.length) return false;
467
+ return g.nodes.some(n => {
468
+ if(exceptId && n.id === exceptId) return false;
469
+ const have = [n.name].concat(Array.isArray(n.aliases) ? n.aliases : []).filter(Boolean).map(s => String(s).toLowerCase());
470
+ return have.some(x => wanted.indexOf(x) >= 0);
471
+ });
472
+ }
473
+ /** 单条 op 的合法性(纯函数)。返回错误文案,null = 通过。 */
474
+ function validateGroupOp(g, op){
475
+ if(!op || typeof op !== "object") return "op 必须是对象";
476
+ const kind = String(op.op || "");
477
+ const has = (id) => g.nodes.some(n => n.id === id);
478
+ if(kind === "addNode"){
479
+ if(!String(op.name || "").trim()) return "addNode 需要 name";
480
+ if(dupNodeName(g, op.name, op.aliases, null)) return "节点名或别名与组内既有节点重复:" + String(op.name).trim();
481
+ return null;
482
+ }
483
+ if(kind === "updateNode"){
484
+ const cur = g.nodes.find(n => n.id === String(op.nodeId || ""));
485
+ if(!cur) return "节点不存在:" + op.nodeId;
486
+ if(op.name !== undefined || op.aliases !== undefined){
487
+ const name = (op.name !== undefined) ? op.name : cur.name;
488
+ const aliases = (op.aliases !== undefined) ? op.aliases : cur.aliases;
489
+ if(dupNodeName(g, name, aliases, cur.id)) return "节点名或别名与组内既有节点重复:" + String(name).trim();
490
+ }
491
+ return null;
492
+ }
493
+ if(kind === "removeNode") return has(String(op.nodeId || "")) ? null : "节点不存在:" + op.nodeId;
494
+ if(kind === "addEdge"){
495
+ if(!has(String(op.from || ""))) return "from 节点不存在:" + op.from;
496
+ if(!has(String(op.to || ""))) return "to 节点不存在:" + op.to;
497
+ if(String(op.from) === String(op.to)) return "不能给自己建关系(from 与 to 相同)";
498
+ const st = Number(op.strength === undefined ? 3 : op.strength);
499
+ if(!(st >= 1 && st <= 5)) return "strength 必须在 1..5";
500
+ if(op.type !== undefined && REL_TYPES.indexOf(String(op.type)) < 0) return "type 不在枚举内:" + op.type;
501
+ return null;
502
+ }
503
+ if(kind === "updateEdge"){
504
+ const e = g.edges.find(x => x.id === String(op.edgeId || ""));
505
+ if(!e) return "边不存在:" + op.edgeId;
506
+ if(op.strength !== undefined){ const st = Number(op.strength); if(!(st >= 1 && st <= 5)) return "strength 必须在 1..5"; }
507
+ if(op.type !== undefined && REL_TYPES.indexOf(String(op.type)) < 0) return "type 不在枚举内:" + op.type;
508
+ return null;
509
+ }
510
+ if(kind === "removeEdge") return g.edges.some(e => e.id === String(op.edgeId || "")) ? null : "边不存在:" + op.edgeId;
511
+ return "未知 op:" + kind;
512
+ }
513
+ /** 应用 ops(纯函数):返回 {group, applied, rejected:[{op,error}]};不修改入参。 */
514
+ function applyGroupOps(group, ops){
515
+ const g = normalizeGroup(JSON.parse(JSON.stringify(group || {})));
516
+ const applied = [], rejected = [];
517
+ (Array.isArray(ops) ? ops : []).forEach(op => {
518
+ const err = validateGroupOp(g, op);
519
+ if(err){ rejected.push({ op, error: err }); return; }
520
+ const kind = String(op.op);
521
+ if(kind === "addNode"){
522
+ const id = "n" + (g.nextNodeSeq++);
523
+ g.nodes.push({
524
+ id, kind: (op.kind === "card") ? "card" : "npc",
525
+ cardId: (op.kind === "card" && op.cardId) ? String(op.cardId) : null,
526
+ name: String(op.name).trim().slice(0, 60),
527
+ aliases: (Array.isArray(op.aliases) ? op.aliases : []).map(String).map(s => s.trim()).filter(Boolean).slice(0, 8),
528
+ orphanOf: null,
529
+ });
530
+ applied.push({ op: kind, nodeId: id });
531
+ } else if(kind === "updateNode"){
532
+ const n = g.nodes.find(x => x.id === String(op.nodeId));
533
+ if(op.name !== undefined) n.name = String(op.name).trim().slice(0, 60);
534
+ if(op.aliases !== undefined) n.aliases = (Array.isArray(op.aliases) ? op.aliases : []).map(String).map(s => s.trim()).filter(Boolean).slice(0, 8);
535
+ applied.push({ op: kind, nodeId: n.id });
536
+ } else if(kind === "removeNode"){
537
+ const nid = String(op.nodeId);
538
+ g.nodes = g.nodes.filter(n => n.id !== nid);
539
+ if(op.removeEdges !== false) g.edges = g.edges.filter(e => e.from !== nid && e.to !== nid);
540
+ applied.push({ op: kind, nodeId: nid, edgesRemoved: (op.removeEdges !== false) });
541
+ } else if(kind === "addEdge"){
542
+ const id = "e" + (g.nextEdgeSeq++);
543
+ g.edges.push({
544
+ id, from: String(op.from), to: String(op.to), type: relTypeOf(op.type),
545
+ label: String(op.label || "").slice(0, 24),
546
+ strength: Math.max(1, Math.min(5, Math.round(Number(op.strength === undefined ? 3 : op.strength)))),
547
+ direction: (op.direction === "mutual") ? "mutual" : "one-way",
548
+ note: String(op.note || "").slice(0, 200), updatedAt: nowSec(),
549
+ });
550
+ applied.push({ op: kind, edgeId: id });
551
+ } else if(kind === "updateEdge"){
552
+ const e = g.edges.find(x => x.id === String(op.edgeId));
553
+ if(op.type !== undefined) e.type = relTypeOf(op.type);
554
+ if(op.label !== undefined) e.label = String(op.label).slice(0, 24);
555
+ if(op.strength !== undefined) e.strength = Math.max(1, Math.min(5, Math.round(Number(op.strength))));
556
+ if(op.direction !== undefined) e.direction = (op.direction === "mutual") ? "mutual" : "one-way";
557
+ if(op.note !== undefined) e.note = String(op.note).slice(0, 200);
558
+ e.updatedAt = nowSec();
559
+ applied.push({ op: kind, edgeId: e.id });
560
+ } else if(kind === "removeEdge"){
561
+ const eid = String(op.edgeId);
562
+ g.edges = g.edges.filter(e => e.id !== eid);
563
+ applied.push({ op: kind, edgeId: eid });
564
+ }
565
+ });
566
+ g.updatedAt = nowSec();
567
+ return { group: g, applied, rejected };
568
+ }
569
+ /**
570
+ * 生成【关系】段(纯函数)。默认只写出边(我 → 他)—— 卡片是以"我"为视角的;
571
+ * includeIncoming 时追加一节「他们眼中的我」(扮演时知道对方怎么看我,是剧情张力来源,默认关)。
572
+ */
573
+ function renderRelationsSection(myNodeId, nodes, edges, opts){
574
+ const o = opts || {};
575
+ const byId = new Map((Array.isArray(nodes) ? nodes : []).map(n => [n.id, n]));
576
+ const nameOf = (id) => { const n = byId.get(id); return n ? (n.name || "未命名") : "(未知)"; };
577
+ const all = (Array.isArray(edges) ? edges : []).filter(e => byId.has(e.from) && byId.has(e.to));
578
+ const outgoing = all.filter(e => e.from === myNodeId);
579
+ const incoming = all.filter(e => e.to === myNodeId && e.direction !== "mutual");
580
+ const order = (t) => { const i = REL_TYPES.indexOf(t); return i < 0 ? REL_TYPES.length : i; };
581
+ 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[other]).localeCompare(nameOf(b[other]));
583
+ const lines = ["【关系】"];
584
+ if(!outgoing.length) lines.push("- (暂无)");
585
+ else outgoing.slice().sort(byTypeThenStrength("to")).forEach(e => lines.push(fmt(e, e.to)));
586
+ if(o.includeIncoming && incoming.length){
587
+ lines.push("", "【他们眼中的我】");
588
+ incoming.slice().sort(byTypeThenStrength("from")).forEach(e => lines.push(fmt(e, e.from)));
589
+ }
590
+ return lines.join("\n");
591
+ }
592
+ /**
593
+ * 把段文本并入 description:有标记区就**整段替换**,没有就末尾追加;section 为空 = 移除标记区。
594
+ * 幂等:同一段内容渲染两次结果逐字相同(tests/relations.test.mjs 钉住)。
595
+ */
596
+ function spliceRelations(description, section, meta){
597
+ const desc = String(description || "");
598
+ const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
599
+ const block = section ? (REL_BEGIN + (meta ? " " + meta : "") + " -->\n" + section + "\n" + REL_END) : "";
600
+ if(i >= 0 && j > i){
601
+ const before = desc.slice(0, i).replace(/\s*$/, "");
602
+ const after = desc.slice(j + REL_END.length);
603
+ const head = block ? (before + (before ? "\n\n" : "") + block) : before;
604
+ return (head + after).replace(/\n{3,}/g, "\n\n").trim();
605
+ }
606
+ if(!block) return desc;
607
+ const head = desc.replace(/\s*$/, "");
608
+ return (head + (head ? "\n\n" : "") + block).trim();
609
+ }
610
+ /**
611
+ * 跨卡体检(纯函数)。members / cards 由调用方给:
612
+ * members = 组内卡的 id 列表;cards = [{id, name, description, constantTokens, tokenBudget}]
613
+ */
614
+ function checkGroup(group, members, cards){
615
+ const g = normalizeGroup(group);
616
+ const issues = [];
617
+ const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
618
+ const nodeIds = new Set(g.nodes.map(n => n.id));
619
+ const byId = new Map((Array.isArray(cards) ? cards : []).map(c => [c.id, c]));
620
+ const dangling = g.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
621
+ if(dangling.length) add("error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
622
+ const orphans = g.nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
623
+ if(orphans.length) add("warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
624
+ const seen = new Map();
625
+ g.nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
626
+ const k = String(x || "").toLowerCase();
627
+ if(!k) return;
628
+ if(!seen.has(k)) seen.set(k, []);
629
+ if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
630
+ }));
631
+ const dup = [...seen.entries()].filter(p => p[1].length > 1);
632
+ if(dup.length) add("error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
633
+ const unknown = g.edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
634
+ if(unknown.length) add("warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
635
+ const memberCards = (Array.isArray(members) ? members : []).map(m => byId.get(m)).filter(Boolean);
636
+ const unrendered = [], stale = [];
637
+ memberCards.forEach(c => {
638
+ const desc = String(c.description || "");
639
+ const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
640
+ if(i < 0 || j < 0){ unrendered.push(c.id); return; }
641
+ const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
642
+ if(!m || Number(m[1]) !== g.rev) stale.push(c.id);
643
+ });
644
+ if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
645
+ if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
646
+ // 群聊版的「单次触发」陷阱:每张卡的 constant 每轮都进上下文,卡越多固定开销越大
647
+ const constTokens = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
648
+ const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
649
+ if(memberCards.length > 1 && constTokens > minBudget * 0.8){
650
+ add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻条目合计约 " + constTokens + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
651
+ }
652
+ return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length, constantTokens: constTokens, minBudget } };
653
+ }
654
+
655
+ /** 读分组:主根优先,回退旧根(与角色库同规)。 */
656
+ async function readGroupRaw(id){
657
+ const file = safeGroupId(id) + ".json";
658
+ for(const dir of groupDirs()){
659
+ try{
660
+ const raw = await readFile(join(dir, file), "utf8");
661
+ const obj = JSON.parse(raw);
662
+ if(obj && typeof obj === "object") return { raw, obj };
663
+ }catch{}
664
+ }
665
+ return null;
666
+ }
667
+ async function listGroups(){
668
+ await ensureGroupDir();
669
+ const seen = new Map();
670
+ for(const dir of groupDirs()){
671
+ let files = [];
672
+ try{ files = await readdir(dir); }catch{ continue; }
673
+ for(const f of files){
674
+ if(!f.endsWith(".json")) continue;
675
+ const id = f.replace(/\.json$/, "");
676
+ if(seen.has(id)) continue;
677
+ try{
678
+ const obj = JSON.parse(await readFile(join(dir, f), "utf8"));
679
+ seen.set(id, normalizeGroup(obj));
680
+ }catch{}
681
+ }
682
+ }
683
+ return [...seen.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
684
+ }
685
+ /** 写分组:读盘上 rev 再 +1(与 persistDraftSync 同思路),只写主根。 */
686
+ async function writeGroup(group){
687
+ await ensureGroupDir();
688
+ const cur = normalizeGroup(group);
689
+ let rev = cur.rev || 0;
690
+ const prev = await readGroupRaw(cur.id);
691
+ if(prev && prev.obj && typeof prev.obj.rev === "number") rev = prev.obj.rev + 1;
692
+ else if(!prev) rev = 1;
693
+ cur.rev = rev;
694
+ cur.updatedAt = nowSec();
695
+ await writeFile(groupPath(cur.id), JSON.stringify(cur, null, 2), "utf8");
696
+ return cur;
697
+ }
698
+ /** 删除:两个根里的同名副本都删(否则回退读会把刚删的组「复活」,与 deleteLibraryEntry 同规)。 */
699
+ async function deleteGroupFile(id){
700
+ const file = safeGroupId(id) + ".json";
701
+ let removed = 0;
702
+ for(const dir of groupDirs()){
703
+ try{ await unlink(join(dir, file)); removed++; }catch{}
704
+ }
705
+ return removed;
706
+ }
707
+
387
708
  function libDirs(){ return dataRoots("cc-library"); }
388
709
  function libDir(){ return libDirs()[0]; }
389
710
  async function ensureLibDir(){
@@ -610,6 +931,9 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
610
931
  tags,
611
932
  tagsTotal: allTags.length,
612
933
  descriptionSnippet: String(data.description || "").slice(0,80),
934
+ // 0.8.0 关系图谱:卡属于哪个分组(**单一真源** —— 分组文件里不存成员列表,避免双写漂移)。
935
+ // 继承既有值,所以「改卡内容再保存」不会把卡踢出分组。
936
+ groupId: (existingMeta && existingMeta.groupId) ? String(existingMeta.groupId) : null,
613
937
  createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
614
938
  updatedAt: now,
615
939
  };
@@ -669,6 +993,72 @@ async function loadLibraryEntry(id){
669
993
  if(!obj || !obj.draft) throw new Error("文件格式错误");
670
994
  return obj;
671
995
  }
996
+ /** 0.8.0:组内成员卡的 id —— 由库条目 meta.groupId 派生(分组文件里刻意不存成员列表)。 */
997
+ async function groupMemberIds(groupId){
998
+ const gid = safeGroupId(groupId);
999
+ const entries = await listLibraryEntries();
1000
+ return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
1001
+ }
1002
+ /** 0.8.0:体检 / 渲染需要的卡片摘要(只读;卡不在库里就跳过)。 */
1003
+ async function libraryCardsFor(ids){
1004
+ const out = [];
1005
+ for(const id of (Array.isArray(ids) ? ids : [])){
1006
+ try{
1007
+ const obj = JSON.parse((await readLibraryRaw(id)).raw);
1008
+ const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
1009
+ const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
1010
+ out.push({
1011
+ id: String(id),
1012
+ name: String(d.name || ""),
1013
+ description: String(d.description || ""),
1014
+ constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
1015
+ tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
1016
+ });
1017
+ }catch{}
1018
+ }
1019
+ return out;
1020
+ }
1021
+ /** 0.8.0:就地改某个库条目的 meta(写在解析到的那一份上,与 renameLibraryEntry 同规,不搬家)。 */
1022
+ async function patchLibraryMeta(id, patch){
1023
+ const found = await readLibraryRaw(id);
1024
+ const obj = JSON.parse(found.raw);
1025
+ if(!obj || !obj.meta) throw new Error("文件格式错误");
1026
+ Object.assign(obj.meta, patch || {});
1027
+ obj.meta.updatedAt = nowSec();
1028
+ await writeFile(found.path, JSON.stringify(obj, null, 2), "utf8");
1029
+ return obj;
1030
+ }
1031
+ /**
1032
+ * 0.8.0:卡被删除后,把各分组里指向它的节点降级为 orphan。
1033
+ * **不删边** —— 关系是用户手工建立的资产,卡没了不代表关系该消失;节点保留名字,体检会报 orphan-node。
1034
+ */
1035
+ async function orphanCardNodes(cardId, cardName){
1036
+ const touched = [];
1037
+ for(const g of await listGroups()){
1038
+ let hit = false;
1039
+ g.nodes.forEach(n => {
1040
+ if(n.kind === "card" && n.cardId === cardId){
1041
+ n.cardId = null;
1042
+ n.orphanOf = String(cardName || n.name || "").slice(0, 60);
1043
+ hit = true;
1044
+ }
1045
+ });
1046
+ if(hit){ await writeGroup(g); touched.push(g.id); }
1047
+ }
1048
+ return touched;
1049
+ }
1050
+ /** 0.8.0:卡改名后同步各分组里对应节点的 name(按 cardId 匹配;别名不动 —— 那是用户为它写的称呼)。 */
1051
+ async function syncCardNodeName(cardId, name){
1052
+ const touched = [];
1053
+ const clean = String(name || "").trim().slice(0, 60);
1054
+ if(!clean) return touched;
1055
+ for(const g of await listGroups()){
1056
+ let hit = false;
1057
+ g.nodes.forEach(n => { if(n.kind === "card" && n.cardId === cardId && n.name !== clean){ n.name = clean; hit = true; } });
1058
+ if(hit){ await writeGroup(g); touched.push(g.id); }
1059
+ }
1060
+ return touched;
1061
+ }
672
1062
  /**
673
1063
  * 删除:**两个根里的同名副本都要删**,否则读盘回退会把刚删掉的卡「复活」。
674
1064
  * 一个都没删到时仍然抛错(保持「删不存在的 id 是失败」的既有语义)。
@@ -1079,7 +1469,10 @@ function apply(ctx, config){
1079
1469
  // 允许保存未完全校验的草稿,但至少需要 name?不强制,meta 会兜底“未命名角色”
1080
1470
  const res = await saveLibraryEntry(draft, { id: targetId || undefined, name: args.name });
1081
1471
  writeDraftOrigin(k, res.id, res.meta.name);
1082
- return ok({ id: res.id, meta: res.meta, action: targetId ? "updated" : "created" });
1472
+ // 0.8.0 修:action 用 saveLibraryEntry 的实测判定(它按「该 id 的文件是否已存在」算)。
1473
+ // 旧写法 `targetId ? "updated" : "created"` 在「传一个库里不存在的新 id」时会谎报 updated ——
1474
+ // 而那正是群聊场景「从母卡派生出一批角色卡」的唯一路径,模型会以为自己覆盖了别人。
1475
+ return ok({ id: res.id, meta: res.meta, action: res.action });
1083
1476
  }
1084
1477
  case "cc_loadFromLibrary":{
1085
1478
  const id = args.id;
@@ -1100,8 +1493,10 @@ function apply(ctx, config){
1100
1493
  await deleteLibraryEntry(id);
1101
1494
  // 删掉的正是当前草稿的来源卡 → 清掉关联,否则下次保存会「更新」一张已不存在的卡
1102
1495
  if(wasOrigin) writeDraftOrigin(k, null);
1496
+ // 0.8.0:关系图谱里不能留下指向已删卡的节点(降级为 orphan 并保留边,见 orphanCardNodes)
1497
+ const groupsTouched = await orphanCardNodes(id, "");
1103
1498
  const entries = await listLibraryEntries();
1104
- return ok({ id, remaining: entries.length, originCleared: wasOrigin });
1499
+ return ok({ id, remaining: entries.length, originCleared: wasOrigin, groupsTouched });
1105
1500
  }
1106
1501
  case "cc_setOrigin":{
1107
1502
  // 前端显式设/清「当前草稿的来源 id」:id 传 null/空 = 清(清空草稿、另存为新时用)
@@ -1116,7 +1511,9 @@ function apply(ctx, config){
1116
1511
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
1117
1512
  if(typeof newName!=="string" || !newName.trim()) return fail("invalid-name","name 必填");
1118
1513
  const meta = await renameLibraryEntry(id, newName);
1119
- return ok({ meta, syncedDraftName: true });
1514
+ // 0.8.0:图谱节点名跟着改(按 cardId 匹配)——否则卡改名后关系里还挂着旧名
1515
+ const groupsTouched = await syncCardNodeName(id, newName);
1516
+ return ok({ meta, syncedDraftName: true, groupsTouched });
1120
1517
  }
1121
1518
  case "cc_getLibraryEntry":{
1122
1519
  const id = args.id;
@@ -1124,6 +1521,119 @@ function apply(ctx, config){
1124
1521
  const obj = await loadLibraryEntry(id);
1125
1522
  return ok(obj);
1126
1523
  }
1524
+ // ———————————————— 人物关系图谱(0.8.0)————————————————
1525
+ case "cc_listGroups":{
1526
+ const groups = await listGroups();
1527
+ const entries = await listLibraryEntries();
1528
+ const countOf = (gid) => entries.filter(e => String(e.groupId || "") === gid).length;
1529
+ return ok({ groups: groups.map(g => ({
1530
+ id: g.id, name: g.name, note: g.note,
1531
+ nodeCount: g.nodes.length, edgeCount: g.edges.length,
1532
+ memberCount: countOf(g.id), rev: g.rev, updatedAt: g.updatedAt,
1533
+ })) });
1534
+ }
1535
+ case "cc_createGroup":{
1536
+ const name = String(args.name || "").trim().slice(0, 60);
1537
+ if(!name) return fail("invalid-name", "分组名不能为空");
1538
+ const g = await writeGroup({ id: newGroupId(), name, note: String(args.note || "").slice(0, 200), nodes: [], edges: [] });
1539
+ return ok({ group: g });
1540
+ }
1541
+ case "cc_renameGroup":{
1542
+ const found = await readGroupRaw(args.id);
1543
+ if(!found) return fail("not-found", "分组不存在: " + String(args.id));
1544
+ const g = normalizeGroup(found.obj);
1545
+ if(typeof args.name === "string" && args.name.trim()) g.name = args.name.trim().slice(0, 60);
1546
+ if(typeof args.note === "string") g.note = args.note.slice(0, 200);
1547
+ return ok({ group: await writeGroup(g) });
1548
+ }
1549
+ case "cc_deleteGroup":{
1550
+ const removed = await deleteGroupFile(args.id);
1551
+ if(!removed) return fail("not-found", "分组不存在: " + String(args.id));
1552
+ const gid = safeGroupId(args.id);
1553
+ const entries = await listLibraryEntries();
1554
+ let memberCleared = 0;
1555
+ for(const e of entries){
1556
+ if(String(e.groupId || "") !== gid) continue;
1557
+ try{ await patchLibraryMeta(e.id, { groupId: null }); memberCleared += 1; }catch{}
1558
+ }
1559
+ return ok({ deleted: removed, memberCleared });
1560
+ }
1561
+ case "cc_setCardGroup":{
1562
+ const cardId = String(args.cardId || "");
1563
+ if(!cardId) return fail("invalid-card", "需要 cardId");
1564
+ const want = (args.groupId === null || args.groupId === undefined || args.groupId === "") ? null : safeGroupId(args.groupId);
1565
+ if(want){
1566
+ const found = await readGroupRaw(want);
1567
+ if(!found) return fail("not-found", "分组不存在: " + want);
1568
+ const g = normalizeGroup(found.obj);
1569
+ // 入组时若还没有该卡的节点就自动补一个:用户只需要"把卡拉进组",不必手工建节点
1570
+ let node = g.nodes.find(n => n.kind === "card" && n.cardId === cardId);
1571
+ if(!node){
1572
+ const card = (await libraryCardsFor([cardId]))[0];
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 });
1579
+ }
1580
+ await patchLibraryMeta(cardId, { groupId: null });
1581
+ return ok({ cardId, groupId: null });
1582
+ }
1583
+ case "cc_getGroup":{
1584
+ const found = await readGroupRaw(args.id);
1585
+ if(!found) return fail("not-found", "分组不存在: " + String(args.id));
1586
+ const g = normalizeGroup(found.obj);
1587
+ const cards = await libraryCardsFor(await groupMemberIds(g.id));
1588
+ return ok({ group: g, members: cards.map(c => ({ id: c.id, name: c.name })), rev: g.rev });
1589
+ }
1590
+ case "cc_patchGroup":{
1591
+ const found = await readGroupRaw(args.id);
1592
+ if(!found) return fail("not-found", "分组不存在: " + String(args.id));
1593
+ const cur = normalizeGroup(found.obj);
1594
+ if(typeof args.expectedRev === "number" && args.expectedRev !== cur.rev){
1595
+ return fail("stale-rev", "分组已被外部修改(期望 rev=" + args.expectedRev + ",实际 rev=" + cur.rev + ")—— 先重新读取再改", { staleRev: true, expectedRev: args.expectedRev, actualRev: cur.rev });
1596
+ }
1597
+ const applied = applyGroupOps(cur, args.ops);
1598
+ if(!applied.applied.length) return ok({ group: cur, applied: [], rejected: applied.rejected, changed: false });
1599
+ const saved = await writeGroup(applied.group);
1600
+ return ok({ group: saved, applied: applied.applied, rejected: applied.rejected, changed: true });
1601
+ }
1602
+ case "cc_groupCheck":{
1603
+ const found = await readGroupRaw(args.id);
1604
+ if(!found) return fail("not-found", "分组不存在: " + String(args.id));
1605
+ const g = normalizeGroup(found.obj);
1606
+ const memberIds = await groupMemberIds(g.id);
1607
+ const cards = await libraryCardsFor(memberIds);
1608
+ return ok(Object.assign({ groupId: g.id, rev: g.rev, name: g.name }, checkGroup(g, memberIds, cards)));
1609
+ }
1610
+ case "cc_renderRelations":{
1611
+ // 渲染进**当前草稿**(与工具半共用同一份草稿文件);要落库仍需再存一次
1612
+ const k = draftKeyFrom(ctx, args);
1613
+ const origin = readDraftOrigin(k);
1614
+ const cardId = args.cardId ? String(args.cardId) : ((origin && origin.libraryId) || "");
1615
+ if(!cardId) return fail("no-card", "这张草稿还没入库(没有来源卡 id):先在工坊「保存当前」或显式传 cardId");
1616
+ const mine = (await listLibraryEntries()).find(e => e.id === cardId);
1617
+ const groupId = args.groupId ? safeGroupId(args.groupId) : ((mine && mine.groupId) ? String(mine.groupId) : "");
1618
+ if(!groupId) return fail("no-group", "这张卡还没有加入任何分组");
1619
+ const found = await readGroupRaw(groupId);
1620
+ if(!found) return fail("not-found", "分组不存在: " + groupId);
1621
+ const g = normalizeGroup(found.obj);
1622
+ const myNode = g.nodes.find(n => n.kind === "card" && n.cardId === cardId);
1623
+ if(!myNode) return fail("no-node", "分组里没有这张卡的节点:先在工坊关系视图里把它加入分组");
1624
+ const section = renderRelationsSection(myNode.id, g.nodes, g.edges, { includeIncoming: args.includeIncoming === true });
1625
+ const draft = getDraftFor(k);
1626
+ const before = String((draft.data && draft.data.description) || "");
1627
+ const after = spliceRelations(before, section, "rev=" + g.rev + " group=" + g.id);
1628
+ const changed = before !== after;
1629
+ if(args.dryRun === true) return ok({ changed, preview: after, section, groupId: g.id, rev: g.rev });
1630
+ if(changed){
1631
+ const copy = JSON.parse(JSON.stringify(draft));
1632
+ copy.data.description = after;
1633
+ setDraftFor(k, copy);
1634
+ }
1635
+ return ok({ changed, groupId: g.id, rev: g.rev, description: after });
1636
+ }
1127
1637
  case "cc_importFromPng":{
1128
1638
  const b64 = args.b64;
1129
1639
  if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
@@ -1245,6 +1755,11 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
1245
1755
  persistDraftSync, loadPersistedDraftSync, libDirs, libDir, getDraftFor, setDraftFor, cardFingerprint, normalizeCardShape,
1246
1756
  // 草稿来源 id 的 sidecar(0.5.0):两半同构,测试直接交叉读写验证互通
1247
1757
  readDraftOrigin, writeDraftOrigin, originFilePath,
1248
- listLibraryEntries, saveLibraryEntry, loadLibraryEntry, deleteLibraryEntry, renameLibraryEntry };
1758
+ listLibraryEntries, saveLibraryEntry, loadLibraryEntry, deleteLibraryEntry, renameLibraryEntry,
1759
+ // 人物关系图谱(0.8.0):纯函数 + 落盘,供 tests/relations.test.mjs 直接断言、并与工具半逐字比对同构实现
1760
+ groupDirs, groupDir, groupPath, safeGroupId, newGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
1761
+ renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, deleteGroupFile,
1762
+ groupMemberIds, libraryCardsFor, patchLibraryMeta, orphanCardNodes, syncCardNodeName, estimateTokensLite,
1763
+ REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
1249
1764
 
1250
1765
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-cc-studio",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
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/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/data-root.test.mjs",
42
42
  "prepublishOnly": "npm test"
43
43
  },
44
44
  "dsh": {