@xia-sc/dsh-cc-studio 0.4.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * CCv3 工坊:校验 + 共享草稿(供 CC 模式的 LLM Tools 与浏览器胶囊同步)+ 已存角色侧边栏落盘。
4
4
  */
5
5
  import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises";
6
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7
7
  import { createHash } from "node:crypto";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { join } from "node:path";
@@ -97,6 +97,38 @@ function makeDefaultDraft(){
97
97
  }
98
98
  };
99
99
  }
100
+ /**
101
+ * 草稿形状规范化(0.7.1,B1,与 lib/agent.js 同构)。
102
+ *
103
+ * DSH 对工具/远端返回值做 lossless-JSON 校验:**任何嵌套 `undefined` 都会让整次调用报错**。
104
+ * 外部导入的卡(PNG / CHARX)或早期版本写下的草稿可能缺 `spec_version` / `creation_date` 这类字段,
105
+ * 前端直接读它就会渲染出 `undefined`、导出的卡也缺字段。所以读盘恢复时按空卡模板补齐缺失键:
106
+ * 只补 `undefined`,不覆盖已有值、不改类型(类型问题交给校验报)。
107
+ */
108
+ function normalizeCardShape(draft){
109
+ const tpl = makeDefaultDraft();
110
+ if(!draft || typeof draft !== "object") return tpl;
111
+ const out = {
112
+ spec: (draft.spec !== undefined && draft.spec !== null) ? draft.spec : tpl.spec,
113
+ spec_version: (draft.spec_version !== undefined && draft.spec_version !== null) ? draft.spec_version : tpl.spec_version,
114
+ data: {},
115
+ };
116
+ const src = (draft.data && typeof draft.data === "object") ? draft.data : {};
117
+ for(const k of Object.keys(tpl.data)){
118
+ out.data[k] = (src[k] !== undefined) ? src[k] : tpl.data[k];
119
+ }
120
+ for(const k of Object.keys(src)){ if(!(k in out.data)) out.data[k] = src[k]; }
121
+ const book = (out.data.character_book && typeof out.data.character_book === "object") ? out.data.character_book : {};
122
+ const bTpl = tpl.data.character_book;
123
+ const nb = {};
124
+ for(const k of Object.keys(bTpl)){ nb[k] = (book[k] !== undefined) ? book[k] : bTpl[k]; }
125
+ for(const k of Object.keys(book)){ if(!(k in nb)) nb[k] = book[k]; }
126
+ if(!Array.isArray(nb.entries)) nb.entries = [];
127
+ if(!nb.extensions || typeof nb.extensions !== "object") nb.extensions = {};
128
+ out.data.character_book = nb;
129
+ if(!out.data.extensions || typeof out.data.extensions !== "object") out.data.extensions = {};
130
+ return out;
131
+ }
100
132
  if(!globalThis.__CC_DRAFTS__) globalThis.__CC_DRAFTS__ = new Map();
101
133
  // —— 落盘根(0.3.6 起统一):DSH_HOME 优先,homedir()/.dsh 兜底 ——
102
134
  // 预设目录一直走这里;草稿与角色库在 0.3.5 及以前写死 homedir()/.dsh,现在统一到同一套根,
@@ -132,6 +164,27 @@ function safeDraftFile(key){
132
164
  }
133
165
  function draftPath(key){ return join(draftDir(), safeDraftFile(key)); }
134
166
  /** 读盘用:主根没有就回退旧根(0.3.6 之前草稿写死的地方)。找不到返回 null。 */
167
+ // —— 草稿的「来源 id」sidecar(0.5.0,与 lib/agent.js 同构)——
168
+ // 记「这份草稿是从哪张已存卡载入的」,让「载入 → 改 → 保存」更新原卡而不是新建一张同名重复卡。
169
+ // 浏览器侧刷新页面会丢掉内存里的 currentLibraryId,所以这份关联必须落盘;存 sidecar 而不是塞进
170
+ // draft.extensions,是因为后者会跟着导出进用户的卡里。
171
+ function originFilePath(key){ return join(draftDir(), safeDraftFile(key).replace(/\.json$/, ".origin.json")); }
172
+ function readDraftOrigin(key){
173
+ try{
174
+ const p = originFilePath(key);
175
+ if(!existsSync(p)) return null;
176
+ const obj = JSON.parse(readFileSync(p, "utf8"));
177
+ if(obj && typeof obj.libraryId === "string" && obj.libraryId) return obj;
178
+ }catch{}
179
+ return null;
180
+ }
181
+ function writeDraftOrigin(key, libraryId, name){
182
+ try{
183
+ if(!libraryId){ try{ unlinkSync(originFilePath(key)); }catch{} return; }
184
+ mkdirSync(draftDir(), { recursive: true });
185
+ writeFileSync(originFilePath(key), JSON.stringify({ key: String(key), libraryId: String(libraryId), name: String(name||""), savedAt: nowSec() }, null, 2), "utf8");
186
+ }catch(e){ try{ console.error("[dsh-cc-studio] persist draft origin failed:", (e && e.message) || e); }catch{} }
187
+ }
135
188
  function findDraftPath(key){
136
189
  const file = safeDraftFile(key);
137
190
  for(const dir of draftDirs()){
@@ -154,17 +207,36 @@ function loadPersistedDraftSync(key){
154
207
  function persistDraftSync(key, draft){
155
208
  try{
156
209
  mkdirSync(draftDir(), { recursive: true });
157
- writeFileSync(draftPath(key), JSON.stringify({ key: String(key), updatedAt: nowSec(), draft }, null, 2), "utf8");
158
- }catch(e){ try{ console.error("[dsh-cc-studio] persist draft failed:", (e && e.message) || e); }catch{} }
210
+ // revision(0.6.0,P0-6):与 lib/agent.js 同构 —— 工具半与宿主半各写各的文件、各持各的内存,
211
+ // **只有草稿文件里的 rev 是共同真相**。工坊这边每写一次盘就 +1,工具侧下次调用读到新 rev 就知道
212
+ // 「草稿被外部换过了」,从而重载而不是拿过期前提去写(否则会静默覆盖用户在工坊里的修改)。
213
+ let rev = 1;
214
+ try{
215
+ const prev = JSON.parse(readFileSync(draftPath(key), "utf8"));
216
+ if(prev && typeof prev.rev === "number" && prev.rev > 0) rev = prev.rev + 1;
217
+ }catch{}
218
+ writeFileSync(draftPath(key), JSON.stringify({ key: String(key), updatedAt: nowSec(), rev, draft }, null, 2), "utf8");
219
+ return rev;
220
+ }catch(e){ try{ console.error("[dsh-cc-studio] persist draft failed:", (e && e.message) || e); }catch{} return null; }
159
221
  }
160
222
  function draftMeta(key){
161
223
  const k = key || "default";
162
- if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false });
224
+ if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false, newReason: null });
163
225
  return globalThis.__CC_DRAFT_META__.get(k);
164
226
  }
227
+ /** 卡片内容指纹(忽略 modification_date):判断「这次写入到底有没有真的改变内容」(N4,与工具半同构)。 */
228
+ function cardFingerprint(draft){
229
+ try{
230
+ const d = JSON.parse(JSON.stringify((draft && draft.data) ? draft.data : {}));
231
+ delete d.modification_date;
232
+ return JSON.stringify(d);
233
+ }catch{ return ""; }
234
+ }
165
235
  function draftStatusFor(key, draft){
166
236
  const m = draftMeta(key);
167
- return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data) ? draft.data.creation_date : null, key: String(key) };
237
+ // B1(0.7.1):缺 `creation_date` 的外部导入卡不能再漏出 `undefined`(工具侧会因此整次调用失败,
238
+ // 前端也会把 undefined 渲染成字面量)。
239
+ return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data && draft.data.creation_date !== undefined) ? draft.data.creation_date : null, key: String(key) };
168
240
  }
169
241
  /**
170
242
  * 解析「这次调用读写哪把草稿槽」,并把来源一并带出来(诊断用)。
@@ -261,20 +333,25 @@ function getDraftFor(key){
261
333
  // fix #2:内存 miss 时先尝试从磁盘恢复,而非静默建空
262
334
  const restored = loadPersistedDraftSync(k);
263
335
  if(restored){
264
- globalThis.__CC_DRAFTS__.set(k, restored);
265
- const m = draftMeta(k); m.isNew = false; m.recovered = true;
336
+ globalThis.__CC_DRAFTS__.set(k, normalizeCardShape(restored)); // B1:补齐外部导入卡缺失的字段
337
+ const m = draftMeta(k); m.isNew = false; m.recovered = true; m.newReason = null;
266
338
  } else {
267
339
  globalThis.__CC_DRAFTS__.set(k, makeDefaultDraft());
268
- const m = draftMeta(k); m.isNew = true; m.recovered = false;
340
+ const m = draftMeta(k); m.isNew = true; m.recovered = false; m.newReason = "welcome";
269
341
  }
270
342
  }
271
343
  return globalThis.__CC_DRAFTS__.get(k);
272
344
  }
273
345
  function setDraftFor(key, draft){
274
- draft.data.modification_date = nowSec();
275
346
  const k = key || "default";
347
+ const prev = globalThis.__CC_DRAFTS__.get(k);
348
+ // N4(0.7.0,与 lib/agent.js 同构):内容一字未变时不写盘、不递增 rev。工坊侧每次编辑都调
349
+ // cc_setDraft,同值覆盖(失焦、重复输入、只动了别的槽)不该白耗一个版本号 —— 否则模型手上的
350
+ // rev 前提会被「其实没发生的变化」打掉一次,而它只能看到一句「草稿已被外部修改」。
351
+ if(prev && cardFingerprint(prev) === cardFingerprint(draft)) return draft;
352
+ draft.data.modification_date = nowSec();
276
353
  globalThis.__CC_DRAFTS__.set(k, draft);
277
- const m = draftMeta(k); m.isNew = false; m.recovered = false;
354
+ const m = draftMeta(k); m.isNew = false; m.recovered = false; m.newReason = null;
278
355
  persistDraftSync(k, draft);
279
356
  return draft;
280
357
  }
@@ -307,6 +384,327 @@ function isCcPreset(ctx, sessionId){
307
384
  }
308
385
 
309
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
+
310
708
  function libDirs(){ return dataRoots("cc-library"); }
311
709
  function libDir(){ return libDirs()[0]; }
312
710
  async function ensureLibDir(){
@@ -524,13 +922,18 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
524
922
  const data = draft && draft.data ? draft.data : {};
525
923
  const now = nowSec();
526
924
  const loreCount = Array.isArray(data.character_book?.entries) ? data.character_book.entries.length : 0;
527
- const tags = Array.isArray(data.tags) ? data.tags.slice(0,6).map(String) : [];
925
+ const allTags = Array.isArray(data.tags) ? data.tags.map(String) : [];
926
+ const tags = allTags.slice(0, 6);
528
927
  return {
529
928
  id: String(id),
530
929
  name: (data.name && String(data.name).trim()) || (existingMeta && existingMeta.name) || "未命名角色",
531
930
  loreCount,
532
931
  tags,
932
+ tagsTotal: allTags.length,
533
933
  descriptionSnippet: String(data.description || "").slice(0,80),
934
+ // 0.8.0 关系图谱:卡属于哪个分组(**单一真源** —— 分组文件里不存成员列表,避免双写漂移)。
935
+ // 继承既有值,所以「改卡内容再保存」不会把卡踢出分组。
936
+ groupId: (existingMeta && existingMeta.groupId) ? String(existingMeta.groupId) : null,
534
937
  createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
535
938
  updatedAt: now,
536
939
  };
@@ -574,13 +977,15 @@ async function saveLibraryEntry(draft, opts){
574
977
  id = String(Date.now())+"-"+Math.random().toString(36).slice(2,6);
575
978
  }
576
979
  const meta = makeLibraryEntryMeta(draft, existing ? existing.meta : null, id);
577
- // 若调用方显式传 name,覆盖 meta.name(不改 draft.data.name,保持原样)
578
- if(opts && typeof opts.name==="string" && opts.name.trim()){
579
- meta.name = String(opts.name).trim().slice(0,60);
980
+ // 改名 = 单一事实来源:meta.name 与 draft.data.name 一起改(否则「列表名 / 导出名」两套,用户改名看不出效果)
981
+ if(opts && typeof opts.name === "string" && opts.name.trim()){
982
+ const n = String(opts.name).trim().slice(0,60);
983
+ meta.name = n;
984
+ draft.data.name = n;
580
985
  }
581
986
  const payload = { id, meta, draft };
582
987
  await writeFile(join(dir, id+".json"), JSON.stringify(payload, null, 2), "utf8");
583
- return { id, meta, draft };
988
+ return { id, meta, draft, action: existing ? "updated" : "created" };
584
989
  }
585
990
  async function loadLibraryEntry(id){
586
991
  await ensureLibDir();
@@ -588,6 +993,72 @@ async function loadLibraryEntry(id){
588
993
  if(!obj || !obj.draft) throw new Error("文件格式错误");
589
994
  return obj;
590
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
+ }
591
1062
  /**
592
1063
  * 删除:**两个根里的同名副本都要删**,否则读盘回退会把刚删掉的卡「复活」。
593
1064
  * 一个都没删到时仍然抛错(保持「删不存在的 id 是失败」的既有语义)。
@@ -611,6 +1082,8 @@ async function renameLibraryEntry(id, newName){
611
1082
  if(!name) throw new Error("name 不能为空");
612
1083
  obj.meta.name = name;
613
1084
  obj.meta.updatedAt = nowSec();
1085
+ // 单一事实来源(0.5.0):列表名与卡内 data.name 一起改 —— 此前只改 meta,侧边栏改了名、导出的卡里还是旧名
1086
+ if(obj.draft && obj.draft.data) obj.draft.data.name = name;
614
1087
  await writeFile(found.path, JSON.stringify(obj, null, 2), "utf8");
615
1088
  return obj.meta;
616
1089
  }
@@ -871,7 +1344,8 @@ function apply(ctx, config){
871
1344
  const k = parts.key;
872
1345
  const d = getDraftFor(k);
873
1346
  const status = draftStatusFor(k, d);
874
- const payload = { draft: d, key: k, keySource: parts.source, isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? k : null), draftStatus: status };
1347
+ const origin = readDraftOrigin(k);
1348
+ const payload = { draft: d, key: k, keySource: parts.source, isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? k : null), draftStatus: status, originId: origin ? origin.libraryId : null };
875
1349
  // 只在「当前槽是个新空壳」时才去盘上翻别的槽(issue #5 的救回入口)。
876
1350
  // 而且只翻**这次错位真正会牵涉到的另一把槽**,不是把所有会话的槽都端出来:
877
1351
  // · 当前是会话槽(session-<id>)→ 候选只有 default —— 旧前端在没有会话 id 时
@@ -927,6 +1401,7 @@ function apply(ctx, config){
927
1401
  if(patch.mes_example!==undefined) cur.data.mes_example = String(patch.mes_example).slice(0,4000);
928
1402
  if(Array.isArray(patch.tags)) cur.data.tags = patch.tags.map(String).slice(0,12);
929
1403
  if(patch.creator_notes!==undefined) cur.data.creator_notes = String(patch.creator_notes).slice(0,2000);
1404
+ if(patch.creator!==undefined) cur.data.creator = String(patch.creator).slice(0,120); // N1:作者名此前没有写入路径
930
1405
  if(patch.world && typeof patch.world==="object"){
931
1406
  cur.data.extensions = cur.data.extensions || {};
932
1407
  cur.data.extensions.cc_world = Object.assign({}, cur.data.extensions.cc_world||{}, patch.world);
@@ -981,29 +1456,54 @@ function apply(ctx, config){
981
1456
  }
982
1457
  case "cc_saveToLibrary":{
983
1458
  // args: {draft?, id?, name?, sessionId?} draft 缺省则取当前会话草稿
1459
+ const k = draftKeyFrom(ctx, args);
984
1460
  let draft = args.draft;
985
1461
  if(!draft || typeof draft!=="object"){
986
- const k = draftKeyFrom(ctx, args);
987
1462
  draft = JSON.parse(JSON.stringify(getDraftFor(k)));
988
1463
  }
1464
+ // 不传 id 时沿用草稿的**来源 id**:工坊里「载入 → 改 → 保存」应当是更新原卡,
1465
+ // 而不是新建一张同名重复卡(刷新页面会丢掉内存里的 currentLibraryId,所以来源落在盘上)。
1466
+ const explicitId = (typeof args.id==="string" && args.id) ? args.id : "";
1467
+ const origin = explicitId ? null : readDraftOrigin(k);
1468
+ const targetId = explicitId || (origin ? origin.libraryId : "");
989
1469
  // 允许保存未完全校验的草稿,但至少需要 name?不强制,meta 会兜底“未命名角色”
990
- const res = await saveLibraryEntry(draft, { id: args.id, name: args.name });
991
- return ok(res);
1470
+ const res = await saveLibraryEntry(draft, { id: targetId || undefined, name: args.name });
1471
+ writeDraftOrigin(k, res.id, res.meta.name);
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 });
992
1476
  }
993
1477
  case "cc_loadFromLibrary":{
994
1478
  const id = args.id;
995
1479
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
996
1480
  const obj = await loadLibraryEntry(id);
997
- // 载入到当前会话草稿
1481
+ // 载入到当前会话草稿,并记下来源 id(sidecar):之后不传 id 保存就是更新这一张
998
1482
  const k = draftKeyFrom(ctx, args);
999
1483
  setDraftFor(k, JSON.parse(JSON.stringify(obj.draft)));
1000
- return ok({ draft: obj.draft, meta: obj.meta, key: k, draftStatus: draftStatusFor(k, obj.draft) });
1484
+ writeDraftOrigin(k, obj.id, obj.meta ? obj.meta.name : "");
1485
+ return ok({ draft: obj.draft, meta: obj.meta, key: k, originId: obj.id, draftStatus: draftStatusFor(k, obj.draft) });
1001
1486
  }
1002
1487
  case "cc_deleteFromLibrary":{
1003
1488
  const id = args.id;
1004
1489
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
1490
+ const k = draftKeyFrom(ctx, args);
1491
+ const origin = readDraftOrigin(k);
1492
+ const wasOrigin = !!(origin && origin.libraryId === id);
1005
1493
  await deleteLibraryEntry(id);
1006
- return ok({ ok:true, id });
1494
+ // 删掉的正是当前草稿的来源卡 → 清掉关联,否则下次保存会「更新」一张已不存在的卡
1495
+ if(wasOrigin) writeDraftOrigin(k, null);
1496
+ // 0.8.0:关系图谱里不能留下指向已删卡的节点(降级为 orphan 并保留边,见 orphanCardNodes)
1497
+ const groupsTouched = await orphanCardNodes(id, "");
1498
+ const entries = await listLibraryEntries();
1499
+ return ok({ id, remaining: entries.length, originCleared: wasOrigin, groupsTouched });
1500
+ }
1501
+ case "cc_setOrigin":{
1502
+ // 前端显式设/清「当前草稿的来源 id」:id 传 null/空 = 清(清空草稿、另存为新时用)
1503
+ const k = draftKeyFrom(ctx, args);
1504
+ const id = (typeof args.id === "string" && args.id) ? args.id : null;
1505
+ writeDraftOrigin(k, id, args.name);
1506
+ return ok({ key: k, originId: id });
1007
1507
  }
1008
1508
  case "cc_renameInLibrary":{
1009
1509
  const id = args.id;
@@ -1011,7 +1511,9 @@ function apply(ctx, config){
1011
1511
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
1012
1512
  if(typeof newName!=="string" || !newName.trim()) return fail("invalid-name","name 必填");
1013
1513
  const meta = await renameLibraryEntry(id, newName);
1014
- return ok({ meta });
1514
+ // 0.8.0:图谱节点名跟着改(按 cardId 匹配)——否则卡改名后关系里还挂着旧名
1515
+ const groupsTouched = await syncCardNodeName(id, newName);
1516
+ return ok({ meta, syncedDraftName: true, groupsTouched });
1015
1517
  }
1016
1518
  case "cc_getLibraryEntry":{
1017
1519
  const id = args.id;
@@ -1019,6 +1521,119 @@ function apply(ctx, config){
1019
1521
  const obj = await loadLibraryEntry(id);
1020
1522
  return ok(obj);
1021
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
+ }
1022
1637
  case "cc_importFromPng":{
1023
1638
  const b64 = args.b64;
1024
1639
  if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
@@ -1137,7 +1752,14 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
1137
1752
  draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision,
1138
1753
  // 落盘根与 lib 层 CRUD(供回归测试直接断言「DSH_HOME 优先 + 读时回退」)
1139
1754
  dshHomeDir, legacyDshHomeDir, dataRoots, draftDirs, draftDir, draftPath, findDraftPath, safeDraftFile,
1140
- persistDraftSync, loadPersistedDraftSync, libDirs, libDir,
1141
- listLibraryEntries, saveLibraryEntry, loadLibraryEntry, deleteLibraryEntry, renameLibraryEntry };
1755
+ persistDraftSync, loadPersistedDraftSync, libDirs, libDir, getDraftFor, setDraftFor, cardFingerprint, normalizeCardShape,
1756
+ // 草稿来源 id 的 sidecar(0.5.0):两半同构,测试直接交叉读写验证互通
1757
+ readDraftOrigin, writeDraftOrigin, originFilePath,
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 };
1142
1764
 
1143
1765