@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/README.md +13 -4
- package/lib/agent.js +508 -4
- package/lib/client.js +331 -7
- package/lib/index.js +519 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工
|
|
|
14
14
|
|
|
15
15
|
| 项目 | 值 |
|
|
16
16
|
| --- | --- |
|
|
17
|
-
| 包名 / 版本 | `@xia-sc/dsh-cc-studio` `0.
|
|
17
|
+
| 包名 / 版本 | `@xia-sc/dsh-cc-studio` `0.8.1` |
|
|
18
18
|
| 兼容 | DSH `>=0.1.5-rc.1`(含**桌面版**)、Node `>=22`、**零运行依赖**;逐版本实测见 [兼容与权限](#兼容与权限) |
|
|
19
19
|
| 落盘位置 | `<DSH_HOME>/cc-library/`(角色卡)、`<DSH_HOME>/cc-drafts/`(会话草稿);`DSH_HOME` 默认 `~/.dsh`,旧位置仍读得到 |
|
|
20
20
|
| 文档 | 用户文档=本文件;改代码前必读 [AGENTS.md](./AGENTS.md);完整历史 [CHANGELOG.md](./CHANGELOG.md) |
|
|
@@ -117,7 +117,7 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
117
117
|
- **导入导出**:`⬆ 导入 JSON/PNG/CHARX` 自动识别容器;`⬇ JSON`、`⬇ PNG`(1×1 占位图)、`⬆ 写入 PNG`(写入你上传的任意 PNG,自动剥离旧 `ccv3/chara` 块并重算 CRC32)、`⬇ CHARX`(打包 `card.json`)。
|
|
118
118
|
- **中英双语 + 深浅色**:完整 `zh / en` 词表,跟随 `设置 → 通用 → 语言` 实时切换;配色走 `var(--dsw-alias-*)`,主操作固定品牌紫 `#7c5cff`。
|
|
119
119
|
|
|
120
|
-
### CC 模式(模型侧
|
|
120
|
+
### CC 模式(模型侧 23 个 Tool)
|
|
121
121
|
|
|
122
122
|
**6 步工作流 + 共创约束**:每步前必须先问偏好,严禁无讨论就一次性填满;世界书的完成态是「**≥5 条且至少 1 条共创**」(纯自动条目不算完成)。每次返回只有一份 `workflow.next`(下一步)+ `workflow.state`(四项三态进度)+ `message`(给用户的一句话),另有 `build` 标明插件版本。
|
|
123
123
|
|
|
@@ -127,6 +127,7 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
127
127
|
| 世界书质量 | `cc_simulate_trigger`(拿一句用户会说的话验命中)/ `cc_lint_lorebook`(静态体检,含**最坏情况单次触发量**)/ `cc_patch_book_meta`(命名 / `scan_depth` / `token_budget` / 递归扫描) |
|
|
128
128
|
| 条目管理 | `cc_update_lorebook_entry` / `cc_delete_lorebook_entries` |
|
|
129
129
|
| 导出与起点 | `cc_preview_export`(体积、估算 token、字段清单、重复内容量)/ `cc_new_card`(破坏性,须用户同意后带 `confirm:true`) |
|
|
130
|
+
| 人物关系(0.8.0) | `cc_get_relations` / `cc_patch_relations` / `cc_render_relations` / `cc_group_check` |
|
|
130
131
|
| 已存库 CRUD | `cc_list_library` / `cc_save_to_library` / `cc_load_from_library` / `cc_delete_from_library` / `cc_rename_in_library` / `cc_get_library_entry` |
|
|
131
132
|
|
|
132
133
|
几条值得知道的约定:
|
|
@@ -136,6 +137,13 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
136
137
|
- 写类工具支持 **`expectedRev` 乐观锁**;不传也会在返回里给 `revDrift` 与 `draftStatus.watchedRev`,多端同时改卡时不会静默覆盖。
|
|
137
138
|
- 工具返回的 `build` 与 `package.json` 的 `version` 同步 —— 能一眼确认当前跑的是哪一版代码。
|
|
138
139
|
|
|
140
|
+
### 群聊与人物关系(0.8.0)
|
|
141
|
+
|
|
142
|
+
做一批「互相有关系的卡」时,CCv3 没有关系字段 —— 硬造会污染导出、别的客户端也不认。所以分成两层:
|
|
143
|
+
|
|
144
|
+
- **插件层图谱是真源**:在工坊右侧「已存角色」里新建**分组**,把同一世界观的角色拉进去,再用分组旁的「关系」按钮打开**关系视图**维护关系 —— 三维 = **方向(A→B 与 B→A 可以不同)× 类型(师徒 / 恋慕 / 敌对…10 类)× 强度(1-5)**。节点有两类:有独立卡的角色,以及**卡内 NPC**(如「柳长老」,没有自己的卡)—— 所以同一张图谱既能服务多卡群聊,也能让单卡「立体」。数据落在 `<DSH_HOME>/cc-groups/`,**不写进卡的任何 CCv3 字段**。
|
|
145
|
+
- **渲染让卡自包含**:「渲染到当前卡」会把「我的关系」写进 `description` 的固定标记区(`<!-- cc-relations:begin --> … <!-- cc-relations:end -->`),分享出去的卡别人拿到就能用;重复渲染是**替换**,不会越写越长。模型侧对应 `cc_render_relations`(支持 `dryRun` 预览)。
|
|
146
|
+
- **跨卡体检**(关系视图的「体检」/ `cc_group_check`)会报:孤儿节点(卡已删)、悬空关系、重名节点、未渲染或已过期的关系段,以及**群聊每轮固定开销**(成员卡常驻条目之和 vs 预算)—— 群聊的开销是「乘法」:5 张卡各带一条常驻,每轮就是 5 份。
|
|
139
147
|
### 校验与导出
|
|
140
148
|
|
|
141
149
|
- 宿主实时校验:`spec: chara_card_v3` / `spec_version: 3.0` / `group_only_greetings` 必填、主图标唯一、正则合法;`cc_validate` 另给逐项 errors 与 warnings(缺字段、keys 质量问题、预算等)。
|
|
@@ -163,8 +171,8 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
163
171
|
## 开发与测试
|
|
164
172
|
|
|
165
173
|
```bash
|
|
166
|
-
npm test #
|
|
167
|
-
node tests/tools-contract.test.mjs # 只跑 CC 工具链契约(
|
|
174
|
+
npm test # 12 个文件、512 项断言,全绿才算过
|
|
175
|
+
node tests/tools-contract.test.mjs # 只跑 CC 工具链契约(88 项)
|
|
168
176
|
```
|
|
169
177
|
|
|
170
178
|
- 改 `lib/client.js`:刷新页面即可;改 `lib/index.js` / 预设:**重启 dsh**。
|
|
@@ -174,6 +182,7 @@ node tests/tools-contract.test.mjs # 只跑 CC 工具链契约(84 项
|
|
|
174
182
|
|
|
175
183
|
完整历史见 [CHANGELOG.md](./CHANGELOG.md)。最近三版都是「另一个 LLM 以工具使用者身份跑回归」挖出来的问题:
|
|
176
184
|
|
|
185
|
+
- `0.8.0` **人物关系图谱与群聊分组**:CCv3 没有关系字段 → 插件层图谱(`<DSH_HOME>/cc-groups/`)做真源,三维(方向 × 类型 × 强度),节点分「有独立卡的角色」与「卡内 NPC」;`cc_render_relations` 把关系渲染进 `description` 的固定标记区让卡自包含(幂等、可 `dryRun`、空图谱 = 清空)。新增 4 个工具(19 → 23)与工坊侧的**分组选择器 + 关系视图**(`s.cur === 4`,不进左侧导航)。另修 `cc_save_to_library` 的 `action` 谎报(传库里不存在的 id 派生新卡时报 updated)与 `cc_render_relations` 漏赋值(`changed:true` 却没写进卡)。测试 464 → 509 项。**需重启 dsh(桌面版重启应用)**。- `0.8.1` 修 0.8.0 关系/分组 UI 的三个「点了没反应」:**「+ 组」依赖 `window.prompt`**(内嵌 webview 拦掉原生对话框 → 静默返回)→ 改内联输入框,关系视图的「+ NPC」同改;**面板打开时从没拉过分组列表** → 卡片上的归组下拉只有「未分组」、新建的组不出现,现已补上;**「关系」按钮在「全部/未分组」下静默禁用** → 改为可点并有提示。另外宿主半没重启时会明确提示「请重启 dsh」。**客户端改动刷新页面即可;宿主端点需重启过 dsh**。
|
|
177
186
|
- `0.7.1` 修 **B1**:dsh 重启后第一次 `cc_get_card` 报 `value is not lossless JSON` —— DSH 对返回值做 lossless-JSON 校验,**任何嵌套 `undefined` 都会让整次调用失败**,而外部导入的卡(PNG / CHARX)可能整个缺 `spec_version` / `creation_date`。三层修法:加载即补齐(`normalizeCardShape`)、`creation_date` 兜底 `null`、返回前统一 `sanitizeJson()`。另加 `build` 版本标识与 `watchedRev`,`changed` 与 `rev` 改用同一套判据。
|
|
178
187
|
- `0.7.0` 修 **N2**:长五维 + autoLorebook 原文照搬会让**一句话**吃满整份世界书预算(实测 1139 token / 预算 1200),而调大 `token_budget` 还能把告警一并消掉 → `cc_lint_lorebook` 新增**最坏情况单次触发量**。另修:`creator` 此前**没有写入路径**(validate 却一直催)、主动重置不再被误报「内容可能已丢失」、不传 `expectedRev` 也会提示 `revDrift`、空改动不再消耗 `rev`。
|
|
179
188
|
- `0.6.0` 修 **P0-6**:草稿被外部(工坊 / host 恢复)换掉时模型完全无感知,实测因此误伤过另一张卡 → 每个返回都带 `cardName` / `cardId` / `rev`,`getDraft()` 以盘为准重载,写类工具支持 `expectedRev` 乐观锁。
|
package/lib/agent.js
CHANGED
|
@@ -12,7 +12,7 @@ const name = "dsh-cc-studio-agent";
|
|
|
12
12
|
// 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
|
|
13
13
|
// 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
|
|
14
14
|
// 发版时与 package.json 的 version 同步。
|
|
15
|
-
const BUILD = "0.
|
|
15
|
+
const BUILD = "0.8.1";
|
|
16
16
|
const inject = ["tools", "agents"];
|
|
17
17
|
|
|
18
18
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
@@ -317,6 +317,323 @@ function setDraft(ctx, draft, exec){
|
|
|
317
317
|
return draft;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// ——————————————————————————————————————————————————————————————
|
|
321
|
+
// 人物关系图谱(0.8.0):分组文件 + 三维关系(方向 × 类型 × 强度)
|
|
322
|
+
//
|
|
323
|
+
// 与 lib/index.js **同构实现**(与 dataRoots / safeDraftFile 同模式):同样的常量、同样的纯函数、
|
|
324
|
+
// 同样的落盘规则(写主根、读回退旧根)。tests/relations.test.mjs 会逐字比对两半渲染输出 ——
|
|
325
|
+
// 改这里必须同步改 lib/index.js,反之亦然。
|
|
326
|
+
//
|
|
327
|
+
// 「卡属于哪个组」记在库条目 meta.groupId(单一真源),分组文件里刻意**不存成员列表**。
|
|
328
|
+
// 关系只活在插件侧;要让分享出去的卡自包含,用 cc_render_relations 渲染进 description 的固定标记区。
|
|
329
|
+
// ——————————————————————————————————————————————————————————————
|
|
330
|
+
const REL_TYPES = ["family","romance","mentor","friend","colleague","rival","enemy","debt","superior","other"];
|
|
331
|
+
const REL_TYPE_LABELS = { family:"亲属", romance:"恋慕", mentor:"师徒", friend:"挚友", colleague:"同门", rival:"竞争", enemy:"敌对", debt:"恩义", superior:"上下级", other:"其他" };
|
|
332
|
+
const REL_BEGIN = "<!-- cc-relations:begin";
|
|
333
|
+
const REL_END = "<!-- cc-relations:end -->";
|
|
334
|
+
|
|
335
|
+
function groupDirs(){ return dataRoots("cc-groups"); }
|
|
336
|
+
function groupDir(){ return groupDirs()[0]; }
|
|
337
|
+
function safeGroupId(id){ return String(id == null ? "" : id).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64); }
|
|
338
|
+
function groupPath(id){ return join(groupDir(), safeGroupId(id) + ".json"); }
|
|
339
|
+
function newGroupId(){ return Date.now() + "-" + Math.random().toString(36).slice(2, 6); }
|
|
340
|
+
async function ensureGroupDir(){ try{ await mkdir(groupDir(), { recursive: true }); }catch{} }
|
|
341
|
+
/** 与 lib/index.js 同构:那边叫 estimateTokensLite(宿主半没有 token 估算),这里复用本半已有的 estimateTokens。 */
|
|
342
|
+
function estimateTokensLite(text){ return estimateTokens(text); }
|
|
343
|
+
function relTypeOf(t){ const s = String(t == null ? "" : t); return REL_TYPES.indexOf(s) >= 0 ? s : "other"; }
|
|
344
|
+
function relLabel(edge){ return (edge && edge.label && String(edge.label).trim()) ? String(edge.label).trim() : (REL_TYPE_LABELS[(edge && edge.type) || "other"] || "其他"); }
|
|
345
|
+
function nameOfNode(nodes, id){ const n = (Array.isArray(nodes) ? nodes : []).find(x => x.id === id); return n ? (n.name || "未命名") : "(未知)"; }
|
|
346
|
+
|
|
347
|
+
/** 结构兜底。⚠️ 不丢弃端点不存在的边(那是用户数据),体检报 dangling-edge、渲染时自动跳过。 */
|
|
348
|
+
function normalizeGroup(g){
|
|
349
|
+
const src = (g && typeof g === "object") ? g : {};
|
|
350
|
+
const nodes = Array.isArray(src.nodes) ? src.nodes.filter(n => n && typeof n === "object" && n.id).map(n => ({
|
|
351
|
+
id: String(n.id),
|
|
352
|
+
kind: (n.kind === "card") ? "card" : "npc",
|
|
353
|
+
cardId: (typeof n.cardId === "string" && n.cardId) ? n.cardId : null,
|
|
354
|
+
name: String(n.name || "").slice(0, 60),
|
|
355
|
+
aliases: Array.isArray(n.aliases) ? n.aliases.map(String).map(s => s.trim()).filter(Boolean).slice(0, 8) : [],
|
|
356
|
+
orphanOf: (typeof n.orphanOf === "string" && n.orphanOf) ? n.orphanOf : null,
|
|
357
|
+
})) : [];
|
|
358
|
+
const edges = Array.isArray(src.edges) ? src.edges.filter(e => e && typeof e === "object" && e.id).map(e => ({
|
|
359
|
+
id: String(e.id),
|
|
360
|
+
from: String(e.from || ""),
|
|
361
|
+
to: String(e.to || ""),
|
|
362
|
+
type: relTypeOf(e.type),
|
|
363
|
+
label: String(e.label || "").slice(0, 24),
|
|
364
|
+
strength: Math.max(1, Math.min(5, Math.round(Number(e.strength) || 3))),
|
|
365
|
+
direction: (e.direction === "mutual") ? "mutual" : "one-way",
|
|
366
|
+
note: String(e.note || "").slice(0, 200),
|
|
367
|
+
updatedAt: Number(e.updatedAt) || nowSec(),
|
|
368
|
+
})) : [];
|
|
369
|
+
return {
|
|
370
|
+
id: safeGroupId(src.id),
|
|
371
|
+
name: String(src.name || "").slice(0, 60) || "未命名分组",
|
|
372
|
+
note: String(src.note || "").slice(0, 200),
|
|
373
|
+
nodes, edges,
|
|
374
|
+
nextNodeSeq: Math.max(1, Math.round(Number(src.nextNodeSeq) || (nodes.length + 1))),
|
|
375
|
+
nextEdgeSeq: Math.max(1, Math.round(Number(src.nextEdgeSeq) || (edges.length + 1))),
|
|
376
|
+
rev: Math.max(0, Math.round(Number(src.rev) || 0)),
|
|
377
|
+
updatedAt: Number(src.updatedAt) || nowSec(),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function dupNodeName(g, name, aliases, exceptId){
|
|
381
|
+
const wanted = [String(name || "").trim()].concat(Array.isArray(aliases) ? aliases.map(s => String(s).trim()) : [])
|
|
382
|
+
.filter(Boolean).map(s => s.toLowerCase());
|
|
383
|
+
if(!wanted.length) return false;
|
|
384
|
+
return g.nodes.some(n => {
|
|
385
|
+
if(exceptId && n.id === exceptId) return false;
|
|
386
|
+
const have = [n.name].concat(Array.isArray(n.aliases) ? n.aliases : []).filter(Boolean).map(s => String(s).toLowerCase());
|
|
387
|
+
return have.some(x => wanted.indexOf(x) >= 0);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function validateGroupOp(g, op){
|
|
391
|
+
if(!op || typeof op !== "object") return "op 必须是对象";
|
|
392
|
+
const kind = String(op.op || "");
|
|
393
|
+
const has = (id) => g.nodes.some(n => n.id === id);
|
|
394
|
+
if(kind === "addNode"){
|
|
395
|
+
if(!String(op.name || "").trim()) return "addNode 需要 name";
|
|
396
|
+
if(dupNodeName(g, op.name, op.aliases, null)) return "节点名或别名与组内既有节点重复:" + String(op.name).trim();
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if(kind === "updateNode"){
|
|
400
|
+
const cur = g.nodes.find(n => n.id === String(op.nodeId || ""));
|
|
401
|
+
if(!cur) return "节点不存在:" + op.nodeId;
|
|
402
|
+
if(op.name !== undefined || op.aliases !== undefined){
|
|
403
|
+
const name = (op.name !== undefined) ? op.name : cur.name;
|
|
404
|
+
const aliases = (op.aliases !== undefined) ? op.aliases : cur.aliases;
|
|
405
|
+
if(dupNodeName(g, name, aliases, cur.id)) return "节点名或别名与组内既有节点重复:" + String(name).trim();
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
if(kind === "removeNode") return has(String(op.nodeId || "")) ? null : "节点不存在:" + op.nodeId;
|
|
410
|
+
if(kind === "addEdge"){
|
|
411
|
+
if(!has(String(op.from || ""))) return "from 节点不存在:" + op.from;
|
|
412
|
+
if(!has(String(op.to || ""))) return "to 节点不存在:" + op.to;
|
|
413
|
+
if(String(op.from) === String(op.to)) return "不能给自己建关系(from 与 to 相同)";
|
|
414
|
+
const st = Number(op.strength === undefined ? 3 : op.strength);
|
|
415
|
+
if(!(st >= 1 && st <= 5)) return "strength 必须在 1..5";
|
|
416
|
+
if(op.type !== undefined && REL_TYPES.indexOf(String(op.type)) < 0) return "type 不在枚举内:" + op.type;
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
if(kind === "updateEdge"){
|
|
420
|
+
const e = g.edges.find(x => x.id === String(op.edgeId || ""));
|
|
421
|
+
if(!e) return "边不存在:" + op.edgeId;
|
|
422
|
+
if(op.strength !== undefined){ const st = Number(op.strength); if(!(st >= 1 && st <= 5)) return "strength 必须在 1..5"; }
|
|
423
|
+
if(op.type !== undefined && REL_TYPES.indexOf(String(op.type)) < 0) return "type 不在枚举内:" + op.type;
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if(kind === "removeEdge") return g.edges.some(e => e.id === String(op.edgeId || "")) ? null : "边不存在:" + op.edgeId;
|
|
427
|
+
return "未知 op:" + kind;
|
|
428
|
+
}
|
|
429
|
+
function applyGroupOps(group, ops){
|
|
430
|
+
const g = normalizeGroup(JSON.parse(JSON.stringify(group || {})));
|
|
431
|
+
const applied = [], rejected = [];
|
|
432
|
+
(Array.isArray(ops) ? ops : []).forEach(op => {
|
|
433
|
+
const err = validateGroupOp(g, op);
|
|
434
|
+
if(err){ rejected.push({ op, error: err }); return; }
|
|
435
|
+
const kind = String(op.op);
|
|
436
|
+
if(kind === "addNode"){
|
|
437
|
+
const id = "n" + (g.nextNodeSeq++);
|
|
438
|
+
g.nodes.push({
|
|
439
|
+
id, kind: (op.kind === "card") ? "card" : "npc",
|
|
440
|
+
cardId: (op.kind === "card" && op.cardId) ? String(op.cardId) : null,
|
|
441
|
+
name: String(op.name).trim().slice(0, 60),
|
|
442
|
+
aliases: (Array.isArray(op.aliases) ? op.aliases : []).map(String).map(s => s.trim()).filter(Boolean).slice(0, 8),
|
|
443
|
+
orphanOf: null,
|
|
444
|
+
});
|
|
445
|
+
applied.push({ op: kind, nodeId: id });
|
|
446
|
+
} else if(kind === "updateNode"){
|
|
447
|
+
const n = g.nodes.find(x => x.id === String(op.nodeId));
|
|
448
|
+
if(op.name !== undefined) n.name = String(op.name).trim().slice(0, 60);
|
|
449
|
+
if(op.aliases !== undefined) n.aliases = (Array.isArray(op.aliases) ? op.aliases : []).map(String).map(s => s.trim()).filter(Boolean).slice(0, 8);
|
|
450
|
+
applied.push({ op: kind, nodeId: n.id });
|
|
451
|
+
} else if(kind === "removeNode"){
|
|
452
|
+
const nid = String(op.nodeId);
|
|
453
|
+
g.nodes = g.nodes.filter(n => n.id !== nid);
|
|
454
|
+
if(op.removeEdges !== false) g.edges = g.edges.filter(e => e.from !== nid && e.to !== nid);
|
|
455
|
+
applied.push({ op: kind, nodeId: nid, edgesRemoved: (op.removeEdges !== false) });
|
|
456
|
+
} else if(kind === "addEdge"){
|
|
457
|
+
const id = "e" + (g.nextEdgeSeq++);
|
|
458
|
+
g.edges.push({
|
|
459
|
+
id, from: String(op.from), to: String(op.to), type: relTypeOf(op.type),
|
|
460
|
+
label: String(op.label || "").slice(0, 24),
|
|
461
|
+
strength: Math.max(1, Math.min(5, Math.round(Number(op.strength === undefined ? 3 : op.strength)))),
|
|
462
|
+
direction: (op.direction === "mutual") ? "mutual" : "one-way",
|
|
463
|
+
note: String(op.note || "").slice(0, 200), updatedAt: nowSec(),
|
|
464
|
+
});
|
|
465
|
+
applied.push({ op: kind, edgeId: id });
|
|
466
|
+
} else if(kind === "updateEdge"){
|
|
467
|
+
const e = g.edges.find(x => x.id === String(op.edgeId));
|
|
468
|
+
if(op.type !== undefined) e.type = relTypeOf(op.type);
|
|
469
|
+
if(op.label !== undefined) e.label = String(op.label).slice(0, 24);
|
|
470
|
+
if(op.strength !== undefined) e.strength = Math.max(1, Math.min(5, Math.round(Number(op.strength))));
|
|
471
|
+
if(op.direction !== undefined) e.direction = (op.direction === "mutual") ? "mutual" : "one-way";
|
|
472
|
+
if(op.note !== undefined) e.note = String(op.note).slice(0, 200);
|
|
473
|
+
e.updatedAt = nowSec();
|
|
474
|
+
applied.push({ op: kind, edgeId: e.id });
|
|
475
|
+
} else if(kind === "removeEdge"){
|
|
476
|
+
const eid = String(op.edgeId);
|
|
477
|
+
g.edges = g.edges.filter(e => e.id !== eid);
|
|
478
|
+
applied.push({ op: kind, edgeId: eid });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
g.updatedAt = nowSec();
|
|
482
|
+
return { group: g, applied, rejected };
|
|
483
|
+
}
|
|
484
|
+
function renderRelationsSection(myNodeId, nodes, edges, opts){
|
|
485
|
+
const o = opts || {};
|
|
486
|
+
const byId = new Map((Array.isArray(nodes) ? nodes : []).map(n => [n.id, n]));
|
|
487
|
+
const nameOf = (id) => { const n = byId.get(id); return n ? (n.name || "未命名") : "(未知)"; };
|
|
488
|
+
const all = (Array.isArray(edges) ? edges : []).filter(e => byId.has(e.from) && byId.has(e.to));
|
|
489
|
+
const outgoing = all.filter(e => e.from === myNodeId);
|
|
490
|
+
const incoming = all.filter(e => e.to === myNodeId && e.direction !== "mutual");
|
|
491
|
+
const order = (t) => { const i = REL_TYPES.indexOf(t); return i < 0 ? REL_TYPES.length : i; };
|
|
492
|
+
const fmt = (e, otherId) => "- " + nameOf(otherId) + "(" + relLabel(e) + "|" + e.strength + "/5" + (e.direction === "mutual" ? ",互向" : "") + ")" + (e.note ? ":" + e.note : "");
|
|
493
|
+
const byTypeThenStrength = (other) => (a, b) => (order(a.type) - order(b.type)) || (b.strength - a.strength) || nameOf(a[other]).localeCompare(nameOf(b[other]));
|
|
494
|
+
const lines = ["【关系】"];
|
|
495
|
+
if(!outgoing.length) lines.push("- (暂无)");
|
|
496
|
+
else outgoing.slice().sort(byTypeThenStrength("to")).forEach(e => lines.push(fmt(e, e.to)));
|
|
497
|
+
if(o.includeIncoming && incoming.length){
|
|
498
|
+
lines.push("", "【他们眼中的我】");
|
|
499
|
+
incoming.slice().sort(byTypeThenStrength("from")).forEach(e => lines.push(fmt(e, e.from)));
|
|
500
|
+
}
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
function spliceRelations(description, section, meta){
|
|
504
|
+
const desc = String(description || "");
|
|
505
|
+
const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
|
|
506
|
+
const block = section ? (REL_BEGIN + (meta ? " " + meta : "") + " -->\n" + section + "\n" + REL_END) : "";
|
|
507
|
+
if(i >= 0 && j > i){
|
|
508
|
+
const before = desc.slice(0, i).replace(/\s*$/, "");
|
|
509
|
+
const after = desc.slice(j + REL_END.length);
|
|
510
|
+
const head = block ? (before + (before ? "\n\n" : "") + block) : before;
|
|
511
|
+
return (head + after).replace(/\n{3,}/g, "\n\n").trim();
|
|
512
|
+
}
|
|
513
|
+
if(!block) return desc;
|
|
514
|
+
const head = desc.replace(/\s*$/, "");
|
|
515
|
+
return (head + (head ? "\n\n" : "") + block).trim();
|
|
516
|
+
}
|
|
517
|
+
function checkGroup(group, members, cards){
|
|
518
|
+
const g = normalizeGroup(group);
|
|
519
|
+
const issues = [];
|
|
520
|
+
const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
|
|
521
|
+
const nodeIds = new Set(g.nodes.map(n => n.id));
|
|
522
|
+
const byId = new Map((Array.isArray(cards) ? cards : []).map(c => [c.id, c]));
|
|
523
|
+
const dangling = g.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
|
|
524
|
+
if(dangling.length) add("error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
|
|
525
|
+
const orphans = g.nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
|
|
526
|
+
if(orphans.length) add("warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
|
|
527
|
+
const seen = new Map();
|
|
528
|
+
g.nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
|
|
529
|
+
const k = String(x || "").toLowerCase();
|
|
530
|
+
if(!k) return;
|
|
531
|
+
if(!seen.has(k)) seen.set(k, []);
|
|
532
|
+
if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
|
|
533
|
+
}));
|
|
534
|
+
const dup = [...seen.entries()].filter(p => p[1].length > 1);
|
|
535
|
+
if(dup.length) add("error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
|
|
536
|
+
const unknown = g.edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
|
|
537
|
+
if(unknown.length) add("warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
|
|
538
|
+
const memberCards = (Array.isArray(members) ? members : []).map(m => byId.get(m)).filter(Boolean);
|
|
539
|
+
const unrendered = [], stale = [];
|
|
540
|
+
memberCards.forEach(c => {
|
|
541
|
+
const desc = String(c.description || "");
|
|
542
|
+
const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
|
|
543
|
+
if(i < 0 || j < 0){ unrendered.push(c.id); return; }
|
|
544
|
+
const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
|
|
545
|
+
if(!m || Number(m[1]) !== g.rev) stale.push(c.id);
|
|
546
|
+
});
|
|
547
|
+
if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
|
|
548
|
+
if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
|
|
549
|
+
const constTokens = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
|
|
550
|
+
const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
|
|
551
|
+
if(memberCards.length > 1 && constTokens > minBudget * 0.8){
|
|
552
|
+
add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻条目合计约 " + constTokens + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
|
|
553
|
+
}
|
|
554
|
+
return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length, constantTokens: constTokens, minBudget } };
|
|
555
|
+
}
|
|
556
|
+
async function readGroupRaw(id){
|
|
557
|
+
const file = safeGroupId(id) + ".json";
|
|
558
|
+
for(const dir of groupDirs()){
|
|
559
|
+
try{
|
|
560
|
+
const raw = await readFile(join(dir, file), "utf8");
|
|
561
|
+
const obj = JSON.parse(raw);
|
|
562
|
+
if(obj && typeof obj === "object") return { raw, obj };
|
|
563
|
+
}catch{}
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
async function listGroups(){
|
|
568
|
+
await ensureGroupDir();
|
|
569
|
+
const seen = new Map();
|
|
570
|
+
for(const dir of groupDirs()){
|
|
571
|
+
let files = [];
|
|
572
|
+
try{ files = await readdir(dir); }catch{ continue; }
|
|
573
|
+
for(const f of files){
|
|
574
|
+
if(!f.endsWith(".json")) continue;
|
|
575
|
+
const id = f.replace(/\.json$/, "");
|
|
576
|
+
if(seen.has(id)) continue;
|
|
577
|
+
try{ seen.set(id, normalizeGroup(JSON.parse(await readFile(join(dir, f), "utf8")))); }catch{}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return [...seen.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
581
|
+
}
|
|
582
|
+
async function writeGroup(group){
|
|
583
|
+
await ensureGroupDir();
|
|
584
|
+
const cur = normalizeGroup(group);
|
|
585
|
+
let rev = cur.rev || 0;
|
|
586
|
+
const prev = await readGroupRaw(cur.id);
|
|
587
|
+
if(prev && prev.obj && typeof prev.obj.rev === "number") rev = prev.obj.rev + 1;
|
|
588
|
+
else if(!prev) rev = 1;
|
|
589
|
+
cur.rev = rev;
|
|
590
|
+
cur.updatedAt = nowSec();
|
|
591
|
+
await writeFile(groupPath(cur.id), JSON.stringify(cur, null, 2), "utf8");
|
|
592
|
+
return cur;
|
|
593
|
+
}
|
|
594
|
+
async function groupMemberIds(groupId){
|
|
595
|
+
const gid = safeGroupId(groupId);
|
|
596
|
+
const entries = await listLibraryEntries();
|
|
597
|
+
return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
|
|
598
|
+
}
|
|
599
|
+
/** 体检 / 渲染需要的卡片摘要(卡不在库里就跳过)。 */
|
|
600
|
+
async function libraryCardsFor(ids){
|
|
601
|
+
const out = [];
|
|
602
|
+
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
603
|
+
try{
|
|
604
|
+
const obj = JSON.parse((await readLibraryRaw(id)).raw);
|
|
605
|
+
const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
|
|
606
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
607
|
+
out.push({
|
|
608
|
+
id: String(id),
|
|
609
|
+
name: String(d.name || ""),
|
|
610
|
+
description: String(d.description || ""),
|
|
611
|
+
constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
|
|
612
|
+
tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
|
|
613
|
+
});
|
|
614
|
+
}catch{}
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
/** 解析「这次操作哪个分组」:显式 groupId 优先,否则用当前卡(来源 id → 库 meta.groupId)所属组。 */
|
|
619
|
+
async function resolveGroupFor(ctx, exec, explicitGroupId){
|
|
620
|
+
const k = keyFor(ctx, exec);
|
|
621
|
+
const origin = readOriginSync(k);
|
|
622
|
+
const cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
623
|
+
const entries = await listLibraryEntries();
|
|
624
|
+
const mine = cardId ? entries.find(e => e.id === cardId) : null;
|
|
625
|
+
const groupId = explicitGroupId ? safeGroupId(explicitGroupId) : ((mine && mine.groupId) ? String(mine.groupId) : "");
|
|
626
|
+
const memberCount = {};
|
|
627
|
+
entries.forEach(e => { const gid = String(e.groupId || ""); if(gid) memberCount[gid] = (memberCount[gid] || 0) + 1; });
|
|
628
|
+
const groups = (await listGroups()).map(g => ({ id: g.id, name: g.name, nodeCount: g.nodes.length, edgeCount: g.edges.length, memberCount: memberCount[g.id] || 0 }));
|
|
629
|
+
if(!groupId) return { ok: false, cardId: cardId || null, groupId: "", groups };
|
|
630
|
+
const found = await readGroupRaw(groupId);
|
|
631
|
+
if(!found) return { ok: false, cardId: cardId || null, groupId, groups, error: "分组不存在:" + groupId };
|
|
632
|
+
const g = normalizeGroup(found.obj);
|
|
633
|
+
const myNode = cardId ? (g.nodes.find(n => n.kind === "card" && n.cardId === cardId) || null) : null;
|
|
634
|
+
return { ok: true, cardId, groupId, group: g, myNode, groups };
|
|
635
|
+
}
|
|
636
|
+
|
|
320
637
|
// —— 已存角色库:<主根>/cc-library/<id>.json(读盘回退 0.3.6 之前的 homedir()/.dsh)——
|
|
321
638
|
function libDirs(){ return dataRoots("cc-library"); }
|
|
322
639
|
function libDir(){ return libDirs()[0]; }
|
|
@@ -1055,8 +1372,12 @@ function apply(ctx){
|
|
|
1055
1372
|
const res=await saveLibraryEntry(draft, { id: targetId || undefined, name: args.name });
|
|
1056
1373
|
// 记来源:下次不传 id 时沿用同一张(cc_new_card / 工坊清空草稿会清掉它)
|
|
1057
1374
|
writeOriginSync(k, res.id, res.meta.name);
|
|
1058
|
-
|
|
1059
|
-
|
|
1375
|
+
// 0.8.0 修:action 与文案都用 saveLibraryEntry 的**实测判定**(它按「该 id 的文件是否已存在」算)。
|
|
1376
|
+
// 旧写法 `targetId ? "updated" : "created"` 在「传一个库里不存在的新 id」时会谎报 updated ——
|
|
1377
|
+
// 而那正是群聊场景「从母卡派生出多张角色卡」的唯一路径,模型会以为自己覆盖了别人、不敢继续。
|
|
1378
|
+
const created = res.action === "created";
|
|
1379
|
+
return { ok:true, action: res.action, id:res.id, meta:res.meta, loreCount:res.meta.loreCount,
|
|
1380
|
+
message: (created ? "已新建已存卡「" : "已更新已存卡「") + res.meta.name + "」(id " + res.id + ")。" + (created ? "下次保存(不传 id)会更新这一张。" : "") };
|
|
1060
1381
|
}
|
|
1061
1382
|
});
|
|
1062
1383
|
tool({
|
|
@@ -1295,6 +1616,185 @@ function apply(ctx){
|
|
|
1295
1616
|
}
|
|
1296
1617
|
});
|
|
1297
1618
|
|
|
1619
|
+
// ——————————————————————————————————————————————————————————————
|
|
1620
|
+
// 0.8.0 新增工具:人物关系图谱(4 个)
|
|
1621
|
+
// 关系数据活在插件侧(<DSH_HOME>/cc-groups/),**不写进 CCv3 任何字段**;要让分享出去的卡
|
|
1622
|
+
// 自包含,就用 cc_render_relations 把关系渲染进 description 的固定标记区。
|
|
1623
|
+
// 分组本身由用户在工坊右侧「已存角色」里创建与维护(「同一世界观拉进同一组」),工具侧只读写关系。
|
|
1624
|
+
// ——————————————————————————————————————————————————————————————
|
|
1625
|
+
|
|
1626
|
+
tool({
|
|
1627
|
+
name: "cc_get_relations",
|
|
1628
|
+
description: "【关系·读】查看当前卡所在分组的人物关系网(三维:方向 from→to / 类型 type / 强度 strength 1-5)。默认只回与当前卡相关的边(省上下文)。关系存在插件侧、不写进卡;卡还没入库或没入组时会明确告知并列出现有分组 —— 别硬猜分组 id。改关系用 cc_patch_relations,写进卡里用 cc_render_relations。",
|
|
1629
|
+
parameters: {
|
|
1630
|
+
type:"object",
|
|
1631
|
+
properties:{
|
|
1632
|
+
groupId:{ type:"string", description:"可选:显式指定分组 id(默认用当前卡所属分组)" },
|
|
1633
|
+
scope:{ type:"string", enum:["mine","all"], description:"mine(默认)= 只回与当前卡相关的边;all = 回整组节点与边" },
|
|
1634
|
+
includeNodes:{ type:"boolean", description:"是否附带节点清单(默认 true)" },
|
|
1635
|
+
},
|
|
1636
|
+
additionalProperties:false
|
|
1637
|
+
},
|
|
1638
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1639
|
+
async execute(args, exec){
|
|
1640
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1641
|
+
if(!r.ok){
|
|
1642
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1643
|
+
message: (r.cardId ? "这张卡(" + r.cardId + ")还没有加入任何分组。" : "当前草稿还没有关联已存卡(没有来源 id)。")
|
|
1644
|
+
+ "关系网按分组组织:请让用户在工坊右侧「已存角色」里新建分组、把同一世界观的卡拉进同一组,然后重试。现有分组:"
|
|
1645
|
+
+ (r.groups.length ? r.groups.map(g => g.name + "(" + g.id + "," + g.memberCount + " 卡 / " + g.edgeCount + " 关系)").join(";") : "(暂无)") };
|
|
1646
|
+
}
|
|
1647
|
+
const g = r.group, myNode = r.myNode;
|
|
1648
|
+
const scope = (args.scope === "all") ? "all" : "mine";
|
|
1649
|
+
const edges = (scope === "all" || !myNode) ? g.edges.slice() : g.edges.filter(e => e.from === myNode.id || e.to === myNode.id);
|
|
1650
|
+
const needIds = new Set();
|
|
1651
|
+
edges.forEach(e => { needIds.add(e.from); needIds.add(e.to); });
|
|
1652
|
+
if(myNode) needIds.add(myNode.id);
|
|
1653
|
+
const nodes = g.nodes.filter(n => scope === "all" || needIds.has(n.id) || n.kind === "card");
|
|
1654
|
+
const res = { ok:true,
|
|
1655
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1656
|
+
cardId: r.cardId || null, myNodeId: myNode ? myNode.id : null, scope,
|
|
1657
|
+
edges: edges.map(e => ({
|
|
1658
|
+
id: e.id, from: e.from, to: e.to,
|
|
1659
|
+
fromName: nameOfNode(g.nodes, e.from), toName: nameOfNode(g.nodes, e.to),
|
|
1660
|
+
type: e.type, typeLabel: REL_TYPE_LABELS[e.type] || e.type, label: e.label || "",
|
|
1661
|
+
strength: e.strength, direction: e.direction, note: e.note,
|
|
1662
|
+
mine: myNode ? (e.from === myNode.id ? "out" : (e.to === myNode.id ? "in" : null)) : null,
|
|
1663
|
+
})),
|
|
1664
|
+
groups: r.groups,
|
|
1665
|
+
message: "分组「" + g.name + "」共 " + g.nodes.length + " 个节点 / " + g.edges.length + " 条关系"
|
|
1666
|
+
+ (scope === "mine" ? ";与当前卡相关 " + edges.length + " 条。" : "。") };
|
|
1667
|
+
if(args.includeNodes !== false) res.nodes = nodes.map(n => ({ id:n.id, kind:n.kind, cardId:n.cardId, name:n.name, aliases:n.aliases, orphan: n.kind === "card" && !n.cardId }));
|
|
1668
|
+
return res;
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
tool({
|
|
1673
|
+
name: "cc_patch_relations",
|
|
1674
|
+
description: "【关系·改】增删改人物关系(三维:方向 from→to / 类型 type / 强度 strength)。ops 是动作数组:{op:\"addNode\",kind:\"npc\"|\"card\",cardId?,name,aliases?} / {op:\"updateNode\",nodeId,name?,aliases?} / {op:\"removeNode\",nodeId,removeEdges?} / {op:\"addEdge\",from,to,type,strength,direction,note?} / {op:\"updateEdge\",edgeId,同 addEdge 的字段} / {op:\"removeEdge\",edgeId}。type 取值 family/romance/mentor/friend/colleague/rival/enemy/debt/superior/other;direction:mutual=对等(一条边即可)|one-way=不对称(A→B 与 B→A 可以不同,这正是「三维」的价值)。**只改插件侧图谱**,要进卡请再调 cc_render_relations。删节点/删边是破坏性操作,先与用户确认。",
|
|
1675
|
+
parameters: {
|
|
1676
|
+
type:"object",
|
|
1677
|
+
properties:{
|
|
1678
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
1679
|
+
ops:{ type:"array", items:{ type:"object", additionalProperties:true }, description:"动作数组,逐项见工具说明" },
|
|
1680
|
+
expectedGroupRev:{ type:"number", description:"可选乐观锁:传上次 cc_get_relations 返回的 group.rev。**注意与 expectedRev 不同** —— expectedRev 是草稿的版本号(包装层统一校验),这里校验的是**分组图谱**的版本" },
|
|
1681
|
+
},
|
|
1682
|
+
required:["ops"],
|
|
1683
|
+
additionalProperties:false
|
|
1684
|
+
},
|
|
1685
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1686
|
+
async execute(args, exec){
|
|
1687
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1688
|
+
if(!r.ok){
|
|
1689
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1690
|
+
message: "这张卡还没入库或没加入分组,关系无处可放。请让用户先在工坊右侧「已存角色」里建组并把它拉进去。" };
|
|
1691
|
+
}
|
|
1692
|
+
const g = r.group;
|
|
1693
|
+
// ⚠️ 分组 rev 与草稿 rev 是两件事:expectedGroupRev 校验**图谱**,expectedRev(包装层)校验**草稿**。
|
|
1694
|
+
// 曾经两者同名,结果传分组 rev 会被包装层当成草稿 rev 拒掉(且错误文案说「草稿已被外部修改」,极具误导性)。
|
|
1695
|
+
if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
|
|
1696
|
+
return { ok:false, staleRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
|
|
1697
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1698
|
+
error: "分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重新读一遍再改。" };
|
|
1699
|
+
}
|
|
1700
|
+
const applied = applyGroupOps(g, args.ops);
|
|
1701
|
+
if(!applied.applied.length){
|
|
1702
|
+
return { ok:false, error:"没有任何 op 生效(原因见 rejected)", rejected: applied.rejected, group: { id: g.id, name: g.name, rev: g.rev } };
|
|
1703
|
+
}
|
|
1704
|
+
const saved = await writeGroup(applied.group);
|
|
1705
|
+
return { ok:true, applied: applied.applied, rejected: applied.rejected,
|
|
1706
|
+
group: { id: saved.id, name: saved.name, rev: saved.rev },
|
|
1707
|
+
nodes: saved.nodes.map(n => ({ id:n.id, kind:n.kind, name:n.name, cardId:n.cardId, aliases:n.aliases })),
|
|
1708
|
+
edges: saved.edges.map(e => ({ id:e.id, from:e.from, to:e.to, type:e.type, strength:e.strength, direction:e.direction, note:e.note })),
|
|
1709
|
+
message: "已改 " + applied.applied.length + " 项"
|
|
1710
|
+
+ (applied.rejected.length ? "(" + applied.rejected.length + " 项被拒:" + applied.rejected.map(x => x.error).join(";") + ")" : "")
|
|
1711
|
+
+ "。图谱 rev=" + saved.rev + ";把关系写进卡里请再调 cc_render_relations,并 cc_save_to_library 落库。" };
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
tool({
|
|
1716
|
+
name: "cc_render_relations",
|
|
1717
|
+
description: "【关系·渲染】把当前卡在图谱里的关系渲染进 description 的固定标记区(<!-- cc-relations:begin --> … <!-- cc-relations:end -->),让**导出的卡自包含** —— 别人拿到卡就能看到这些关系,不依赖插件数据。默认只写「我对别人的看法」(出边);includeIncoming:true 时追加一节「他们眼中的我」。dryRun:true 只回预览不改动。渲染后要 cc_save_to_library 才落库;重复渲染是**替换**,不会越写越长。",
|
|
1718
|
+
parameters: {
|
|
1719
|
+
type:"object",
|
|
1720
|
+
properties:{
|
|
1721
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
1722
|
+
includeIncoming:{ type:"boolean", description:"是否额外渲染「他们眼中的我」(默认 false)" },
|
|
1723
|
+
dryRun:{ type:"boolean", description:"true = 只回预览、不改草稿" },
|
|
1724
|
+
expectedGroupRev:{ type:"number", description:"可选乐观锁:**分组** rev 不符则拒绝(与草稿的 expectedRev 不同)" },
|
|
1725
|
+
},
|
|
1726
|
+
additionalProperties:false
|
|
1727
|
+
},
|
|
1728
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1729
|
+
async execute(args, exec){
|
|
1730
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1731
|
+
if(!r.ok){
|
|
1732
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1733
|
+
message: "这张卡还没入库或没加入分组,没有关系可渲染。" };
|
|
1734
|
+
}
|
|
1735
|
+
const g = r.group;
|
|
1736
|
+
if(!r.myNode){
|
|
1737
|
+
return { ok:false, error:"分组「" + g.name + "」里没有这张卡的节点:先在工坊关系视图里把它加入分组(或用 cc_patch_relations 加一个 kind:\"card\" 的节点)。" };
|
|
1738
|
+
}
|
|
1739
|
+
if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
|
|
1740
|
+
return { ok:false, staleRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
|
|
1741
|
+
error:"分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重读再渲染。" };
|
|
1742
|
+
}
|
|
1743
|
+
const section = renderRelationsSection(r.myNode.id, g.nodes, g.edges, { includeIncoming: args.includeIncoming === true });
|
|
1744
|
+
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
1745
|
+
const before = String((cur.data && cur.data.description) || "");
|
|
1746
|
+
const after = spliceRelations(before, section, "rev=" + g.rev + " group=" + g.id);
|
|
1747
|
+
const changed = before !== after;
|
|
1748
|
+
const outgoing = g.edges.filter(e => e.from === r.myNode.id).length;
|
|
1749
|
+
if(args.dryRun === true){
|
|
1750
|
+
return { ok:true, dryRun:true, changed, outgoing, section, preview: after,
|
|
1751
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1752
|
+
message: "预览:将写入 " + outgoing + " 条关系(" + (changed ? "description 会变化" : "与现状一致,无需重写") + ")。" };
|
|
1753
|
+
}
|
|
1754
|
+
if(changed){
|
|
1755
|
+
// ⚠️ 这一行曾经漏掉:changed 算出来了、setDraft 也调了,但 description 还是旧值 —— 表现是
|
|
1756
|
+
// 「返回 changed:true 而卡里什么都没变」,而且不好看出来(rev 照样递增)。
|
|
1757
|
+
cur.data.description = after;
|
|
1758
|
+
setDraft(ctx, cur, exec);
|
|
1759
|
+
}
|
|
1760
|
+
return { ok:true, changed, changedFields: changed ? ["description"] : [], outgoing, description: after,
|
|
1761
|
+
group: { id: g.id, name: g.name, rev: g.rev }, workflow: workflowStatus(cur),
|
|
1762
|
+
message: (changed ? "已把 " + outgoing + " 条关系写进 description 的【关系】段" : "description 里已经是最新关系,无需改动")
|
|
1763
|
+
+ "。注意:这只改了**当前草稿**,要落库请再调 cc_save_to_library(或让用户在工坊点「保存当前」)。" };
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
tool({
|
|
1768
|
+
name: "cc_group_check",
|
|
1769
|
+
description: "【关系·体检】跨卡检查一个分组:孤儿节点(卡已删除)、悬空关系、重名节点(渲染会指向同一个人)、未渲染/已过期的【关系】段,以及**群聊每轮固定开销**(所有成员卡 constant 条目之和 vs 最小预算 —— 群聊时卡越多这块钱越贵,一张卡的单卡体检看不出来)。做完一批群聊卡后调一次。",
|
|
1770
|
+
parameters: {
|
|
1771
|
+
type:"object",
|
|
1772
|
+
properties:{ groupId:{ type:"string", description:"可选:默认用当前卡所属分组" } },
|
|
1773
|
+
additionalProperties:false
|
|
1774
|
+
},
|
|
1775
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
1776
|
+
async execute(args, exec){
|
|
1777
|
+
const r = await resolveGroupFor(ctx, exec, args.groupId);
|
|
1778
|
+
if(!r.ok){
|
|
1779
|
+
return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
1780
|
+
message: "这张卡还没入库或没加入分组,没有可体检的对象。" };
|
|
1781
|
+
}
|
|
1782
|
+
const g = r.group;
|
|
1783
|
+
const memberIds = await groupMemberIds(g.id);
|
|
1784
|
+
const cards = await libraryCardsFor(memberIds);
|
|
1785
|
+
const res = checkGroup(g, memberIds, cards);
|
|
1786
|
+
const errorCount = res.issues.filter(i => i.level === "error").length;
|
|
1787
|
+
return { ok:true,
|
|
1788
|
+
group: { id: g.id, name: g.name, rev: g.rev },
|
|
1789
|
+
issues: res.issues, stats: res.stats,
|
|
1790
|
+
errorCount, warningCount: res.issues.length - errorCount,
|
|
1791
|
+
members: cards.map(c => ({ id: c.id, name: c.name })),
|
|
1792
|
+
message: res.issues.length
|
|
1793
|
+
? "分组「" + g.name + "」发现 " + res.issues.length + " 个问题(" + errorCount + " 个会真的不工作):" + res.issues.map(i => i.message).join(";")
|
|
1794
|
+
: "分组「" + g.name + "」体检通过:" + res.stats.members + " 张成员卡 / " + res.stats.edges + " 条关系,没有结构性问题。" };
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1298
1798
|
// 【导出干跑】补上「五维到底导不导出」这个此前没人能回答的问题
|
|
1299
1799
|
tool({
|
|
1300
1800
|
name: "cc_preview_export",
|
|
@@ -1393,4 +1893,8 @@ function apply(ctx){
|
|
|
1393
1893
|
});
|
|
1394
1894
|
}
|
|
1395
1895
|
|
|
1396
|
-
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync
|
|
1896
|
+
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
1897
|
+
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|
|
1898
|
+
groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
1899
|
+
renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, groupMemberIds, libraryCardsFor,
|
|
1900
|
+
REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
|