@xia-sc/dsh-cc-studio 0.10.3 → 0.11.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 +20 -12
- package/lib/agent.js +142 -8
- package/lib/client.js +289 -51
- package/lib/draft-lock.js +98 -0
- package/lib/index.js +215 -6
- package/package.json +4 -3
- package/presets/cc/agent.cordis.yml +20 -36
- package/presets/cc.patch.yml +20 -36
package/README.md
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
> 从一句话点子到可导入 SillyTavern / Risu 的 `chara_card_v3`。专治「只有点子,世界观薄弱」。
|
|
12
12
|
|
|
13
|
-
DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工坊,配合 `CC 模式` 预设让 LLM 通过
|
|
13
|
+
DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工坊,配合 `CC 模式` 预设让 LLM 通过 33 个 Tool **先问再填**、与你共创角色卡,最后一键导出 `JSON / PNG / CHARX`。
|
|
14
14
|
|
|
15
15
|
| 项目 | 值 |
|
|
16
16
|
| --- | --- |
|
|
17
|
-
| 包名 / 版本 | `@xia-sc/dsh-cc-studio` `0.
|
|
17
|
+
| 包名 / 版本 | `@xia-sc/dsh-cc-studio` `0.11.1` |
|
|
18
18
|
| 兼容 | DSH `>=0.4.5-rc.4`(含**桌面版**)、Node `>=22`、**零运行依赖**;逐版本实测见 [兼容与权限](#兼容与权限) |
|
|
19
|
-
| 落盘位置 | `<DSH_HOME>/cc-library/`(角色卡)、`<DSH_HOME>/cc-drafts
|
|
19
|
+
| 落盘位置 | `<DSH_HOME>/cc-library/`(角色卡)、`<DSH_HOME>/cc-drafts/`(会话草稿;LLM 创作锁 `.lock.json` 与覆盖前备份 `backups/` 也在这里);`DSH_HOME` 默认 `~/.dsh`,旧位置仍读得到 |
|
|
20
20
|
| 文档 | 用户文档=本文件;改代码前必读 [AGENTS.md](./AGENTS.md);完整历史 [CHANGELOG.md](./CHANGELOG.md) |
|
|
21
21
|
|
|
22
22
|
---
|
|
@@ -42,7 +42,7 @@ DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工
|
|
|
42
42
|
dsh --profile <profile> --dump-config
|
|
43
43
|
期望在 `# == @xia-sc/dsh-cc-studio` 那一层下看到两行:`id: dsh-cc-studio`(宿主行)与 `id: preset-cc`(CC 预设行)。
|
|
44
44
|
4. 告诉用户重启:宿主行只在启动时组合 —— CLI 重启 `dsh web`,桌面版退出并重新打开应用。
|
|
45
|
-
5. 重启后可选自检:在「CC 模式」会话里调任意一个 cc_* 工具,返回里带 "build": "0.
|
|
45
|
+
5. 重启后可选自检:在「CC 模式」会话里调任意一个 cc_* 工具,返回里带 "build": "0.11.1" 即说明新代码已加载
|
|
46
46
|
(这也是判断「改了但没重启」的唯一可靠依据)。
|
|
47
47
|
6. 不要做的事:
|
|
48
48
|
- 不要手工把 presets/ 拷进 ~/.dsh/.agent-presets/(dsh >= 0.4.7-alpha.4 已不读该目录,拷了也不生效);
|
|
@@ -58,9 +58,9 @@ DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工
|
|
|
58
58
|
```bash
|
|
59
59
|
# 从 npm 安装(推荐)
|
|
60
60
|
dsh plugin --profile web add @xia-sc/dsh-cc-studio
|
|
61
|
-
dsh plugin --profile web add @xia-sc/dsh-cc-studio@0.
|
|
61
|
+
dsh plugin --profile web add @xia-sc/dsh-cc-studio@0.11.1 # 锁定版本
|
|
62
62
|
|
|
63
|
-
# 从 GitHub 源安装(可加 #v0.
|
|
63
|
+
# 从 GitHub 源安装(可加 #v0.11.1 / #master 指定 tag / 分支)
|
|
64
64
|
dsh plugin --profile web add github:xia-sc/dsh-cc-studio
|
|
65
65
|
|
|
66
66
|
# 从本地仓库安装(开发 / 调试):装出来是 link:,改 lib/*.js 重启即生效
|
|
@@ -94,6 +94,7 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
94
94
|
| 改完代码没变化 | 分三层看:改 `lib/client.js` → 刷新页面;改 `lib/index.js`(宿主)或预设 → **重启 dsh(桌面版重启应用)**;先看一眼工具返回里的 `build` 是不是你以为的版本 |
|
|
95
95
|
| 偶发「本轮运行失败 `tool input is invalid JSON`」 | 模型生成的工具参数 JSON 有语法错误(最常见是尾逗号),DSH provider 严格 `JSON.parse` 后判 `MALFORMED_RESPONSE`,且默认不重试。0.10.2 已在 persona 与五个写类工具描述里要求「严格 JSON + 分小步写(数组字段单独一批)」,**重启 dsh 让新文本生效**;仍偶发就重发同一句话(模型会重新生成),或在 profile 的 `cordis.patch.yml` 给 `llm-deepseek-account` 加 `retryPolicy`(`retryableCodes` 要**写全默认 5 码 + `MALFORMED_RESPONSE`** —— 补丁层对 config 是整条替换、不做深合并),完整片段见 CHANGELOG 0.10.2 |
|
|
96
96
|
| 卡里的换行显示成字面 `\n`(如「说完这句话。\n\n下一段」) | 模型在 tool call 里把换行写成了字面 `\n`(JSON 多转义一层),旧版工具原样落盘。0.10.3 起写类工具统一解码字面 `\n` / `\r` / `\t`(含多层转义),**重启 dsh 生效**。⚠️ 已存卡里已经被写坏的字段不会自动修复 —— 重新调一次对应工具写入那几个字段,或在工坊里手动改回真换行 |
|
|
97
|
+
| LLM 正在创作时,点「载入」另一张卡没反应/只出预览,工坊变只读 | 这是 0.11.0 的**草稿锁**(LLM 创作保护):LLM 写草稿后自动锁住,载入/导入只进只读预览,不会覆盖 LLM 未保存的草稿。让 LLM 调 `cc_release_draft` 交还、或等锁超过 **30 分钟**无写入自动失效,工坊即恢复可编辑;也可以在 设置 →「角色卡工坊」→ 工坊默认行为里关掉「**LLM 创作保护(草稿锁)**」(等价旧行为)。被覆盖前的草稿都在 `<DSH_HOME>/cc-drafts/backups/`(同槽最多 20 份) |
|
|
97
98
|
|
|
98
99
|
---
|
|
99
100
|
|
|
@@ -101,9 +102,9 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
101
102
|
|
|
102
103
|
4. 新建会话,会话模式选 **CC 模式** —— 胶囊随即出现在输入框上方,后台会把 CC 预设挂上。
|
|
103
104
|
2. 直接说需求,例如「我想做雨城记忆典当行老板娘,世界观很薄」。
|
|
104
|
-
3. 模型按 **6
|
|
105
|
-
`cc_get_card`(总结进度 + 提问)→ `cc_patch_character`(气质/关系 → 四件套)→ `cc_patch_world`(世界侧重 → ≥3 维 + `autoLorebook`)→ `cc_add_lorebook_entries`(触发词 → ≥5 条,至少 4 条 `constant`)→ `cc_patch_greetings`(开场场景 → 问候语)→ `cc_validate
|
|
106
|
-
4.
|
|
105
|
+
3. 模型按 **6 步**推进(第 6 步校验通过后再调用 `cc_release_draft` 交还编辑权),每步先用 4-2 个开放问题问你的偏好,再落笔;每次调用 Tool,胶囊与工坊实时刷新:
|
|
106
|
+
`cc_get_card`(总结进度 + 提问)→ `cc_patch_character`(气质/关系 → 四件套)→ `cc_patch_world`(世界侧重 → ≥3 维 + `autoLorebook`)→ `cc_add_lorebook_entries`(触发词 → ≥5 条,至少 4 条 `constant`)→ `cc_patch_greetings`(开场场景 → 问候语)→ `cc_validate`(校验通过)→ `cc_release_draft`(交还草稿编辑权)。
|
|
107
|
+
4. 随时点胶囊进工坊看进度。**LLM 写作期间工坊是只读的**(它持有草稿锁,你载入别的卡只会进入只读预览,不会覆盖它正在写的草稿);校验通过、你确认创作完成后,让 LLM 交还编辑权限(`cc_release_draft`),或等锁 30 分钟无写入自动失效,再在工坊里微调。之后:侧栏 `★ 保存当前`(已载入 ID 时原地覆盖,否则新建)、`+ 另存为新`,或导出 `JSON / PNG / CHARX`;也可以直接对模型说「帮我载入 ID xxxx」。
|
|
107
108
|
|
|
108
109
|
---
|
|
109
110
|
|
|
@@ -116,16 +117,18 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
116
117
|
- **文档式阅读排版**:字段是无边框 textarea、按内容自动撑高,点进去即可改;长文点「⛶」开 720px 大框(带打开时快照,「取消」真回滚)。写作是主线,手动编辑是次要入口。
|
|
117
118
|
- **5 维世界观**:年表 / 势力 / 地理 / 力量体系 / 日常 → `autoLorebook` 自动生成带 `@@position / @@depth / @@activate` 的条目(至少 4 条 `constant`;再次调用覆盖旧自动条目、保留手工条目)。
|
|
118
119
|
- **ID 化 CRUD**:`★ 保存当前`(已载入时原地覆盖)/ `+ 另存为新` / `✎ 重命名` / `+ 新建` / 载入 / 删除,搜索、高亮当前卡,落盘 `<DSH_HOME>/cc-library/<id>.json`。
|
|
120
|
+
- **LLM 创作保护(草稿锁,0.11.0)**:LLM 每次写草稿都会上锁(`<DSH_HOME>/cc-drafts/<槽>.lock.json`),锁 active 期间工坊只读、载入/导入只进**只读预览**(草稿与来源 id 都不写),退出预览即回到 LLM 的草稿;LLM 调 `cc_release_draft` 交还、或锁 30 分钟无写入自动失效后恢复编辑。设置页可关掉该保护(关闭 = 旧行为)。任何覆盖型动作(载入/导入/迁移/force 覆盖)前都会把当前草稿备份到 `<DSH_HOME>/cc-drafts/backups/`(同槽最多 20 份,空壳与同内容不重复备份)
|
|
119
121
|
- **导入导出**:`⬆ 导入 JSON/PNG/CHARX` 自动识别容器;`⬇ JSON`、`⬇ PNG`(4×4 占位图)、`⬆ 写入 PNG`(写入你上传的任意 PNG,自动剥离旧 `ccv3/chara` 块并重算 CRC32)、`⬇ CHARX`(打包 `card.json`)。
|
|
120
122
|
- **中英双语 + 深浅色**:完整 `zh / en` 词表,跟随 `设置 → 通用 → 语言` 实时切换;配色走 `var(--dsw-alias-*)`,主操作固定品牌紫 `#7c5cff`。
|
|
121
123
|
|
|
122
|
-
### CC 模式(模型侧
|
|
124
|
+
### CC 模式(模型侧 33 个 Tool)
|
|
123
125
|
|
|
124
126
|
**6 步工作流 + 共创约束**:每步前必须先问偏好,严禁无讨论就一次性填满;世界书的完成态是「**≥5 条且至少 4 条共创**」(纯自动条目不算完成)。每次返回只有一份 `workflow.next`(下一步)+ `workflow.state`(四项三态进度)+ `message`(给用户的一句话),另有 `build` 标明插件版本。
|
|
125
127
|
|
|
126
128
|
| 分类 | 工具 |
|
|
127
129
|
| --- | --- |
|
|
128
130
|
| 工作流(必调) | `cc_get_card` / `cc_patch_character` / `cc_patch_world` / `cc_add_lorebook_entries` / `cc_patch_greetings` / `cc_validate` |
|
|
131
|
+
| 创作收口(0.11.0) | `cc_release_draft` —— **`cc_validate` 通过(valid=true)= 创作完成,必须立刻调用**,把草稿编辑权交还用户(释放锁,工坊恢复可编辑)。不释放时工坊保持只读,用户只能预览;persona、`cc_validate` 终态与首次上锁提示都会反复引导模型调用它 |
|
|
129
132
|
| 世界书质量 | `cc_simulate_trigger`(拿一句用户会说的话验命中)/ `cc_lint_lorebook`(静态体检,含**最坏情况单次触发量**)/ `cc_patch_book_meta`(命名 / `scan_depth` / `token_budget` / 递归扫描) |
|
|
130
133
|
| 条目管理 | `cc_update_lorebook_entry` / `cc_delete_lorebook_entries` |
|
|
131
134
|
| 导出与起点 | `cc_preview_export`(体积、估算 token、字段清单、重复内容量)/ `cc_new_card`(破坏性,须用户同意后带 `confirm:true`) |
|
|
@@ -139,6 +142,7 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
139
142
|
- `cc_patch_world` 的 **`loreKeys`**:为每一维自报触发词(真实对话里用户会说的实体名)。不给的话自动条目只能用维度名当 key,**真实对话永远命中不了**。同一实体被分给多个维度时会当场警告。
|
|
140
143
|
- **载入后直接保存会更新原卡**(草稿旁记着来源 id),不会再莫名多出一张同名重复卡;改名同时改列表名与卡内 `data.name`。
|
|
141
144
|
- 写类工具支持 **`expectedRev` 乐观锁**;不传也会在返回里给 `revDrift` 与 `draftStatus.watchedRev`,多端同时改卡时不会静默覆盖。
|
|
145
|
+
- **草稿锁(0.11.0)**:模型每次写草稿都会自动上锁(`draftStatus.lock` 能随时看到自己是否持有),工坊随即只读、用户载入只进只读预览;创作完成调用 `cc_release_draft` 交还。锁超过 30 分钟无写入会自动失效(防模型崩溃后把工坊永久锁死),模型再写会自动重新上锁。
|
|
142
146
|
- 工具返回的 `build` 与 `package.json` 的 `version` 同步 —— 能一眼确认当前跑的是哪一版代码。
|
|
143
147
|
- **分组的增删改查模型侧也能做**(0.8.0 补):`cc_create_group` 建组、`cc_set_card_group` 把卡放进/换/移出(`cardId` 省略 = 当前草稿的来源卡,**不传 `groupId` = 移出**)、`cc_delete_group` 是破坏性操作须显式 `confirm:true`。**移出分组不删节点与关系**(挪回来关系还在),**删组不删卡**(只清成员卡的 `meta.groupId`)。
|
|
144
148
|
|
|
@@ -176,8 +180,8 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
176
180
|
## 开发与测试
|
|
177
181
|
|
|
178
182
|
```bash
|
|
179
|
-
npm test #
|
|
180
|
-
node tests/tools-contract.test.mjs # 只跑 CC 工具链契约(
|
|
183
|
+
npm test # 24 个文件、987 项断言,全绿才算过
|
|
184
|
+
node tests/tools-contract.test.mjs # 只跑 CC 工具链契约(125 项)
|
|
181
185
|
node tests/group-layer-guard.test.mjs # 只跑卡组层的耦合守卫(16 项)
|
|
182
186
|
```
|
|
183
187
|
|
|
@@ -188,6 +192,10 @@ node tests/group-layer-guard.test.mjs # 只跑卡组层的耦合守卫(1
|
|
|
188
192
|
|
|
189
193
|
完整历史见 [CHANGELOG.md](./CHANGELOG.md)。最近几版大多是「另一个 LLM 以工具使用者身份跑回归」挖出来的问题:
|
|
190
194
|
|
|
195
|
+
- `0.11.1` **CC 预设对齐 dsh `0.2.1-rc.2` 内置 `standard`**:修掉 `tool-ralph` 漏抄 `disabled`(CC 模式一直多挂一个实验性长循环工具)、`skill-filesystem` / `tool-skill` 被误关(模型没有技能目录与加载器)、以及 `backgroundMode` 等已下线的配置键;补 `time-context`、`tool-web` 的 `fetch` 改回 `true`。`dshReleases` 补 `0.2.1-rc.2`(npm `dsh web` 隔离环境实测)。测试补 CHARX 导入全链路与「按 id 改世界书条目」的回归 → **987 项**。**重启 dsh(桌面端重启应用)生效**。
|
|
196
|
+
|
|
197
|
+
- `0.11.0` **新增 LLM 草稿锁(读写分离)**:LLM 创作期间工坊只读、载入/导入只进只读预览,不再覆盖未保存草稿;`cc_release_draft` 交还编辑权,锁 30 分钟无写入自动失效,设置页可关闭。**修复**:角色卡完成后主动交还锁(persona / `cc_validate` / 首次上锁三处引导)、客户端草稿来源 id 恢复失效;覆盖型载入/导入前自动备份到 `cc-drafts/backups/`。测试 864 → **959 项**。详细见 [CHANGELOG](./CHANGELOG.md)。
|
|
198
|
+
|
|
191
199
|
- `0.10.3` **多行文本被写成字面 `\n`**:用户实机报告(六步全链路 `valid: true`)发现经 `cc_patch_character` / `cc_patch_greetings` / `cc_patch_world` 写入的字符串,换行全部落盘成字面 `\` + `n`,导进 SillyTavern 显示成「说完这句话。\n\n下一段」。取证推翻了「工具多转义」的猜测:工具层往返测试证明真换行原样保留;会话日志里模型 18 次调用有 6 次在 JSON 里把换行写成 `\\n`(多转义一层),而 `alternate_greetings` / lorebook 却写对了 —— 是**模型输出不一致**。修法:写类工具统一 `textArg()` 解码字面 `\n` / `\r` / `\t`(多层转义最多 4 轮收敛),覆盖角色 / 问候语 / 世界书条目 / 书级元信息 / 共享书;工坊保存与导入路径不动。顺带修报告 P2(卡内 `token_budget` 默认 1200 → **2400**)与 P4(autoLorebook 的「力量体系」条目裸 `@@activate` → `@@activate 100`)。测试 858 → **864 项**。**重启 dsh 生效**;已存卡里已写坏的字段需重写。
|
|
192
200
|
|
|
193
201
|
- `0.10.2` **模型偶发一个 JSON 尾逗号,整轮就没了**:报错是 `本轮运行失败 DeepSeek Messages stream: tool input is invalid JSON`。会话日志解帧取证:模型调 `cc_patch_character` 时生成的 arguments 末尾是 `..."tags":[...],}`(尾逗号)。根因在 provider:`@deepseek-ai/dsh-llm-deepseek` 在流结束时对 tool input 严格 `JSON.parse`,失败即 `MALFORMED_RESPONSE`,而默认 `retryableCodes` 不含它 → 不自动重试;工具从未执行、卡片数据没坏。插件能施加影响的只有模型可见文本,所以给 persona 加「工具参数必须是严格合法 JSON(禁尾逗号/注释/单引号/未转义换行),一次只传 1-3 个字段、长字段分多次写」,五个写类工具的 description 同步这句规则,并用测试钉住(preset-install + tools-contract 各 +1 项)。用户侧可选兜底:在 profile `cordis.patch.yml` 给 `llm-deepseek-account` 配 `retryPolicy.retryableCodes` 含 `MALFORMED_RESPONSE`(详见 CHANGELOG 0.10.2)。测试 856 → **858 项**。**重启 dsh(桌面版重启应用)生效**。
|
package/lib/agent.js
CHANGED
|
@@ -14,12 +14,16 @@ import { REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END,
|
|
|
14
14
|
WORLD_DIMS, AUTO_NAMES, isAutoEntry, looksLikeLegacyAuto,
|
|
15
15
|
estimateTokens, lorebookTokens, contentKeyOf, scanSharedEntries,
|
|
16
16
|
assembleScope, groupMetrics, lorebookMetrics, metricsBrief, auditLoreEntries, runGroupChecks } from "./group-layer.js";
|
|
17
|
+
// 草稿锁的判定与常量(0.11.0):两半共用 lib/draft-lock.js —— 「锁算不算 active / 超时算不算过期」只有一份实现。
|
|
18
|
+
// 与 group-layer.js 同一个教训:两半各写一份判定必然漂移,一边说锁住了、另一边照写,比没有锁更危险。
|
|
19
|
+
// 这里只 import 工具半真正用到的:上锁时写 owner、读回时归一、返回时给摘要。
|
|
20
|
+
import { LOCK_OWNER_LLM, normalizeLock, lockSummary } from "./draft-lock.js";
|
|
17
21
|
const name = "dsh-cc-studio-agent";
|
|
18
22
|
// 版本标识(0.7.1):每次工具返回都带 `build`。
|
|
19
23
|
// 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
|
|
20
24
|
// 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
|
|
21
25
|
// 发版时与 package.json 的 version 同步。
|
|
22
|
-
const BUILD = "0.
|
|
26
|
+
const BUILD = "0.11.1";
|
|
23
27
|
const inject = ["tools", "agents"];
|
|
24
28
|
|
|
25
29
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
@@ -146,6 +150,66 @@ function writeOriginSync(key, libraryId, name){
|
|
|
146
150
|
writeFileSync(originPath(key), JSON.stringify({ key: String(key), libraryId: String(libraryId), name: String(name||""), savedAt: nowSec() }, null, 2), "utf8");
|
|
147
151
|
}catch(e){ try{ console.error("[dsh-cc-studio] persist draft origin failed:", (e && e.message) || e); }catch{} }
|
|
148
152
|
}
|
|
153
|
+
// —— 草稿锁的 IO(0.11.0)——
|
|
154
|
+
// 背景:LLM 正在工坊草稿里创作 A 卡时,用户点「载入」B 卡会把同一会话槽的草稿整份替换,
|
|
155
|
+
// LLM 已写入但未保存的内容只能从历史对话里捞。现在工具半**每次写草稿**都给这把槽上一把创作锁,
|
|
156
|
+
// 用户侧(宿主 RPC)看到 active 锁就只读 / 进预览;交还由 LLM 调 `cc_release_draft` 完成,
|
|
157
|
+
// 另有 TTL 兜底(防 LLM 崩溃后把工坊永久锁死)。
|
|
158
|
+
//
|
|
159
|
+
// 判定(active / stale / 能不能写)全在 lib/draft-lock.js,两半共用 —— 这里只负责落盘,
|
|
160
|
+
// 与 .origin.json 同模式:<safeDraftFile 去 .json>.lock.json。**不写进草稿 JSON**:
|
|
161
|
+
// 用户只读与 LLM 交还都不重写草稿,草稿的 `rev` 仍然只反映内容变化。
|
|
162
|
+
function draftLockPath(key){ return join(draftDir(), safeDraftFile(key).replace(/\.json$/, ".lock.json")); }
|
|
163
|
+
/** 读锁:主根没有就回退旧根(与草稿/来源 sidecar 同一套规则)。损坏 / key 不符一律当「无锁」(见 normalizeLock)。 */
|
|
164
|
+
function readDraftLockSync(key){
|
|
165
|
+
const file = safeDraftFile(key).replace(/\.json$/, ".lock.json");
|
|
166
|
+
for(const dir of draftDirs()){
|
|
167
|
+
const p = join(dir, file);
|
|
168
|
+
try{
|
|
169
|
+
if(!existsSync(p)) continue;
|
|
170
|
+
return normalizeLock(JSON.parse(readFileSync(p, "utf8")), String(key));
|
|
171
|
+
}catch(e){}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** 写锁:失败只 console.error 不抛 —— 锁是保护用户数据的附加层,写锁失败不能反过来弄坏一次正常的工具调用。 */
|
|
176
|
+
function writeDraftLockSync(key, lock){
|
|
177
|
+
try{
|
|
178
|
+
mkdirSync(draftDir(), { recursive: true });
|
|
179
|
+
writeFileSync(draftLockPath(key), JSON.stringify(lock, null, 2), "utf8");
|
|
180
|
+
return true;
|
|
181
|
+
}catch(e){ try{ console.error("[dsh-cc-studio] persist draft lock failed:", (e && e.message) || e); }catch{} return false; }
|
|
182
|
+
}
|
|
183
|
+
/** 清锁:两个根都删 —— 旧根若残留同一把锁,回退读会把它当成本槽的锁,LLM 的「交还」就不算数了。 */
|
|
184
|
+
function clearDraftLockSync(key){
|
|
185
|
+
const file = safeDraftFile(key).replace(/\.json$/, ".lock.json");
|
|
186
|
+
for(const dir of draftDirs()){
|
|
187
|
+
try{ unlinkSync(join(dir, file)); }catch(e){}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function draftLockSummaryFor(key){ return lockSummary(readDraftLockSync(key), nowSec()); }
|
|
191
|
+
/**
|
|
192
|
+
* 上锁 / 续期:LLM 每次写草稿(含「内容无变化」的空写)都调这里。
|
|
193
|
+
* `since` 只在第一次上锁时记下来 —— 它回答「从什么时候开始创作」,每次写都重置就没有意义了。
|
|
194
|
+
* `rev` 取本次落盘后的版本(空写时取内存里记着 / 盘上实际那个),**绝不留 undefined**
|
|
195
|
+
* (B1 的 lossless-JSON 教训:一个 undefined 就让整次工具调用报错)。
|
|
196
|
+
*/
|
|
197
|
+
function refreshDraftLock(key, draft, rev){
|
|
198
|
+
const k = String(key);
|
|
199
|
+
const prev = readDraftLockSync(k);
|
|
200
|
+
const now = nowSec();
|
|
201
|
+
const cardName = (draft && draft.data && String(draft.data.name || "").trim()) || "";
|
|
202
|
+
const lock = {
|
|
203
|
+
key: k,
|
|
204
|
+
owner: LOCK_OWNER_LLM,
|
|
205
|
+
since: (prev && prev.since > 0) ? prev.since : now,
|
|
206
|
+
updatedAt: now,
|
|
207
|
+
rev: (typeof rev === "number" && rev > 0) ? rev : ((prev && prev.rev) || 0),
|
|
208
|
+
cardName: cardName.slice(0, 120),
|
|
209
|
+
};
|
|
210
|
+
writeDraftLockSync(k, lock);
|
|
211
|
+
return lock;
|
|
212
|
+
}
|
|
149
213
|
function draftMeta(key){
|
|
150
214
|
const k = String(key || "default");
|
|
151
215
|
if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false, rev: null, pendingFresh: null });
|
|
@@ -171,6 +235,10 @@ function draftStatusFor(key, draft){
|
|
|
171
235
|
cardId: (origin && origin.libraryId) || null,
|
|
172
236
|
cardName: (draft && draft.data && String(draft.data.name||"").trim()) || "",
|
|
173
237
|
rev: (typeof m.rev === "number") ? m.rev : null,
|
|
238
|
+
// 草稿锁摘要(0.11.0):active=true 表示 LLM 正在创作,工坊只读 / 载入只预览。
|
|
239
|
+
// 无锁时**显式 null**(不是省略)—— 客户端与测试都按「有值 = 摘要、null = 无锁」判断,
|
|
240
|
+
// 而 undefined 还会踩 lossless-JSON(B1)。
|
|
241
|
+
lock: draftLockSummaryFor(k),
|
|
174
242
|
// watchedRev 由 tool() 包装在调用前捕获后注入:它 = 「本次调用开始前工具半记着的盘上版本」。
|
|
175
243
|
watchedRev: null,
|
|
176
244
|
};
|
|
@@ -337,17 +405,31 @@ function getDraft(ctx, exec){
|
|
|
337
405
|
function setDraft(ctx, draft, exec){
|
|
338
406
|
const m = draftsMap();
|
|
339
407
|
const k = keyFor(ctx, exec);
|
|
408
|
+
const meta = draftMeta(k);
|
|
340
409
|
const prev = m.get(k);
|
|
341
410
|
// N4(0.7.0):**内容一字未变时不落盘、不递增 rev**。空改动(传了不生效的参数、同值覆盖、
|
|
342
411
|
// 删 0 条条目…)原来也会把 rev 推进一格,于是持有旧 rev 的调用方会无辜失败一次
|
|
343
412
|
// (第四轮实测:`changed: []` 让 rev 7 → 8)。modification_date 也要跟着不动,否则指纹永远不等。
|
|
344
|
-
if(prev && cardFingerprint(prev) === cardFingerprint(draft))
|
|
413
|
+
if(prev && cardFingerprint(prev) === cardFingerprint(draft)){
|
|
414
|
+
// 但「内容没变」不代表 LLM 停手了:锁必须**续期**。updatedAt 是「还在创作」的心跳,
|
|
415
|
+
// 不是「内容变了」的记号 —— 否则 LLM 连着几轮只讨论不改稿,锁会提前超时,
|
|
416
|
+
// 用户又能趁它还在创作时载入别的卡。rev 优先用内存里记着的(上次自己写下的),
|
|
417
|
+
// 再退盘上实际那个,绝不留 undefined(B1)。
|
|
418
|
+
const idleRev = (typeof meta.rev === "number" && meta.rev > 0) ? meta.rev : (draftRevOnDisk(k) || 0);
|
|
419
|
+
refreshDraftLock(k, draft, idleRev);
|
|
420
|
+
return draft;
|
|
421
|
+
}
|
|
345
422
|
draft.data.modification_date = nowSec();
|
|
346
423
|
m.set(k, draft);
|
|
347
424
|
// pendingFresh 刻意不在这里清:新草稿提示由 tool() 包装在**调用之后**统一发,这里清掉就发不出来了。
|
|
348
|
-
|
|
425
|
+
meta.isNew = false; meta.recovered = false;
|
|
349
426
|
const rev = persistDraftSync(k, draft);
|
|
350
|
-
if(typeof rev === "number")
|
|
427
|
+
if(typeof rev === "number"){
|
|
428
|
+
meta.rev = rev; // 自己写的这一次不算「外部修改」
|
|
429
|
+
// 写盘成功后上锁 / 续期(0.11.0):从这一刻起,用户侧对这份草稿的写入一律被拦,
|
|
430
|
+
// 载入/导入只进只读预览 —— 直到 LLM 自己调 cc_release_draft 或锁超时。
|
|
431
|
+
refreshDraftLock(k, draft, rev);
|
|
432
|
+
}
|
|
351
433
|
return draft;
|
|
352
434
|
}
|
|
353
435
|
|
|
@@ -1016,7 +1098,7 @@ function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done
|
|
|
1016
1098
|
+ " " + mark(state.world) + "五维(" + worldFilled + "/5)"
|
|
1017
1099
|
+ " " + mark(state.lore) + "世界书(" + loreCount + (sharedLoreCount ? "+" + sharedLoreCount : "") + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
|
|
1018
1100
|
+ " " + mark(state.greetings) + "问候语"
|
|
1019
|
-
+ (done ? " ·
|
|
1101
|
+
+ (done ? " · 已完成,可导出/保存;记得调用 cc_release_draft 交还编辑权" : "");
|
|
1020
1102
|
}
|
|
1021
1103
|
// —— 世界书口径与体检的实现都在 lib/group-layer.js(0.10.0)——
|
|
1022
1104
|
// estimateTokens / lorebookTokens / lorebookMetrics / auditLoreEntries / worstCaseTrigger 等
|
|
@@ -1045,6 +1127,10 @@ function apply(ctx){
|
|
|
1045
1127
|
const meta = draftMeta(k);
|
|
1046
1128
|
const expected = (args && typeof args.expectedRev === "number") ? args.expectedRev : null;
|
|
1047
1129
|
const beforeRev = draftRevOnDisk(k);
|
|
1130
|
+
// 0.11.0 流程引导:记下本次调用前有没有锁。第一次建立锁时给模型一条明确提示 ——
|
|
1131
|
+
// 只靠 cc_validate 终态的 next 提醒,弱模型经常「写完直接总结」而不调 cc_release_draft,
|
|
1132
|
+
// 工坊就一直只读。让它第一次写草稿时就知道「完成时必须交还」。
|
|
1133
|
+
const lockBefore = readDraftLockSync(k);
|
|
1048
1134
|
// S1 补充(0.7.1):把「本次调用开始前工具半记着的盘上版本」捕下来,返回里一并报出 ——
|
|
1049
1135
|
// 模型可以随时对账:`watchedRev ≠ rev` 就说明这两次调用之间有别人写过。第五轮实测里,
|
|
1050
1136
|
// 测试者想验证「不传 expectedRev 也能发现漂移」,却在工具序列内造不出漂移(自己写的 rev
|
|
@@ -1093,6 +1179,17 @@ function apply(ctx){
|
|
|
1093
1179
|
res.warnings = (Array.isArray(res.warnings) ? res.warnings : []).concat([note]);
|
|
1094
1180
|
}
|
|
1095
1181
|
}
|
|
1182
|
+
// 首次上锁的流程引导(见函数开头 lockBefore 的注释):只在「无锁 → 有锁」这一次出现,
|
|
1183
|
+
// 之后每次写只是续期,不再刷屏。
|
|
1184
|
+
if(!lockBefore){
|
|
1185
|
+
const lockAfter = readDraftLockSync(k);
|
|
1186
|
+
if(lockAfter){
|
|
1187
|
+
const lockNote = "🔒 已为当前草稿建立创作锁(工坊在此期间只读、用户载入只进预览)。创作完成时(cc_validate 通过后)**必须调用 cc_release_draft 交还草稿编辑权**,否则用户无法在工坊微调;你继续写会自动续期,不会重复锁。";
|
|
1188
|
+
if(!(Array.isArray(res.warnings) && res.warnings.indexOf(lockNote) >= 0)){
|
|
1189
|
+
res.warnings = (Array.isArray(res.warnings) ? res.warnings : []).concat([lockNote]);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1096
1193
|
}
|
|
1097
1194
|
}catch(e){}
|
|
1098
1195
|
// B1 的第二道防线:无论各工具返回什么,出门前统一过一遍 JSON 往返,绝不把 `undefined`
|
|
@@ -2465,7 +2562,7 @@ function apply(ctx){
|
|
|
2465
2562
|
|
|
2466
2563
|
tool({
|
|
2467
2564
|
name: "cc_validate",
|
|
2468
|
-
description: "【工作流第6步·收口必调】校验 CC 角色卡(SPEC_V3 + 角色四件套/五维≥3/世界书≥5/问候语齐)。valid=false 按 errors
|
|
2565
|
+
description: "【工作流第6步·收口必调】校验 CC 角色卡(SPEC_V3 + 角色四件套/五维≥3/世界书≥5/问候语齐)。valid=false 按 errors 与用户讨论继续补;**valid=true 即创作完成,必须立刻调用 cc_release_draft 交还草稿编辑权**(不交还的话工坊一直只读,用户无法微调),然后邀请用户在工坊微调后“导出 JSON”或“★ 保存到侧边栏”。全程强调共创:多问用户、多给草案供挑选。",
|
|
2469
2566
|
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
2470
2567
|
output: out({ type:"object", additionalProperties:true }),
|
|
2471
2568
|
async execute(args, exec){
|
|
@@ -2513,14 +2610,19 @@ function apply(ctx){
|
|
|
2513
2610
|
if(budget>0 && used > budget) warnings.push("世界书内容估算约 "+used+" token,已超出 token_budget="+budget+",建议调大预算或精简条目(cc_patch_book_meta)");
|
|
2514
2611
|
// —— 终态:校验通过后 next 不再指向 cc_validate(P0-3:那正是弱模型死循环的诱因)——
|
|
2515
2612
|
const valid = errors.length===0;
|
|
2613
|
+
// 0.11.0:终态要主动提醒交还草稿锁 —— 不交还时工坊一直是只读的,用户只能预览、无法微调,
|
|
2614
|
+
// 而「校验通过」正是「创作告一段落」的唯一信号。保留「校验导出」「保存当前」两个既有指引入口
|
|
2615
|
+
//(工坊左下角入口名与 0.4.1 的 UI 一致;tools-contract ②i 依赖这两处字样)。
|
|
2516
2616
|
const next = valid
|
|
2517
|
-
? "校验通过 ✓ 工作流已完成(step 7/7
|
|
2617
|
+
? "校验通过 ✓ 工作流已完成(step 7/7)。**下一步必须调用 cc_release_draft 交还草稿编辑权**(否则工坊保持只读,用户只能在预览里看、无法微调);交还后引导用户在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘到 ~/.dsh/cc-library。还想打磨就直接说改哪里(你继续写会自动重新上锁)。"
|
|
2518
2618
|
: "校验未通过,按 errors 逐项补齐(严禁跳步),第一项:" + errors[0];
|
|
2519
2619
|
// R1(0.6.0):终态的 `hint` 必须与 `next` 同源 —— 否则会出现「next 说工作流已完成、hint 说下一步必须去校验」
|
|
2520
2620
|
// 这种自相矛盾(弱模型会读信息最全的 hint,于是反复调 cc_validate)。半修的状态机比没修更危险。
|
|
2521
2621
|
const wfDone = Object.assign({}, wf, {
|
|
2522
2622
|
step: valid ? 7 : wf.step,
|
|
2523
2623
|
done: valid,
|
|
2624
|
+
// 模型可见的显式收口信号:valid=true 时 releaseRequired=true,提示它这一步还没做完。
|
|
2625
|
+
releaseRequired: valid,
|
|
2524
2626
|
readyToValidate: !valid && wf.readyToValidate,
|
|
2525
2627
|
next,
|
|
2526
2628
|
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid, wf.sharedLoreCount),
|
|
@@ -2544,10 +2646,40 @@ function apply(ctx){
|
|
|
2544
2646
|
message: valid
|
|
2545
2647
|
? "校验通过 ✓ 工作流完成(世界书 " + wf.loreTotal + " 条"
|
|
2546
2648
|
+ (wf.sharedLoreCount ? " = 卡内 " + wf.loreCount + " + 共享书 " + wf.sharedLoreCount : "")
|
|
2547
|
-
+ " / 估算 " + (used + sharedUsed) + " token
|
|
2649
|
+
+ " / 估算 " + (used + sharedUsed) + " token)。下一步:调用 cc_release_draft 交还草稿锁,用户才能在工坊微调/导出。"
|
|
2548
2650
|
: "校验未通过:" + errors.length + " 项待补。" };
|
|
2549
2651
|
}
|
|
2550
2652
|
});
|
|
2653
|
+
|
|
2654
|
+
// 【交还草稿锁】0.11.0:权限完全由 LLM 掌握 —— 它说创作完了、或用户明确要求强制释放时才放开。
|
|
2655
|
+
// 为什么不做成自动释放:工具半无法判断「这一轮对话是否真的结束」,误放一次,
|
|
2656
|
+
// 用户接着载入别的卡就会把 LLM 还没保存的内容覆盖掉 —— 正是 0.11.0 要修的那个事故。
|
|
2657
|
+
tool({
|
|
2658
|
+
name: "cc_release_draft",
|
|
2659
|
+
description: "【收口·交还草稿】**cc_validate 通过(valid=true)= 创作完成,必须立刻调用本工具**,否则工坊一直只读、用户无法微调(这是工作流的最后一步)。释放当前会话的草稿创作锁,把工坊还给用户编辑。调用前先向用户说明当前卡名与进度(释放后用户就能在工坊里直接改这张卡)。用户明确要求强制释放时也调用;释放后若你再次写草稿,会自动重新锁定;当前没有锁时调用也安全(幂等成功,released:false)。",
|
|
2660
|
+
parameters: { type:"object", properties:{}, additionalProperties:false },
|
|
2661
|
+
output: out({ type:"object", additionalProperties:true }),
|
|
2662
|
+
async execute(args, exec){
|
|
2663
|
+
const k = keyFor(ctx, exec);
|
|
2664
|
+
const lock = readDraftLockSync(k);
|
|
2665
|
+
const st = draftStatusFor(k, getDraft(ctx, exec));
|
|
2666
|
+
const released = !!lock;
|
|
2667
|
+
clearDraftLockSync(k);
|
|
2668
|
+
// 卡名优先用锁里记下的那个:锁可能是草稿还叫旧名时建的,交还时要说清「交还的是哪张卡」。
|
|
2669
|
+
const cardName = (lock && String(lock.cardName || "").trim()) || String(st.cardName || "").trim();
|
|
2670
|
+
const rev = (typeof st.rev === "number" && st.rev > 0) ? st.rev : ((lock && lock.rev) || null);
|
|
2671
|
+
return {
|
|
2672
|
+
ok: true,
|
|
2673
|
+
released,
|
|
2674
|
+
sessionKey: k,
|
|
2675
|
+
cardName: cardName || null,
|
|
2676
|
+
rev,
|
|
2677
|
+
message: released
|
|
2678
|
+
? "已释放草稿锁「" + (cardName || "未命名") + "」:工坊恢复可编辑,用户现在可以载入/导入别的卡,不会覆盖你的草稿。若你继续写草稿,会自动重新锁定。"
|
|
2679
|
+
: "当前没有草稿锁(可能已释放过,或已超时自动失效),无需重复释放。如果你继续写草稿,会自动重新锁定。",
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
});
|
|
2551
2683
|
}
|
|
2552
2684
|
|
|
2553
2685
|
// ——————————————————————————————————————————————————————————————
|
|
@@ -2981,6 +3113,8 @@ async function sharedBookCoversDims(ctx, exec){
|
|
|
2981
3113
|
}
|
|
2982
3114
|
|
|
2983
3115
|
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
3116
|
+
// 草稿锁(0.11.0):与宿主半同构的落盘与摘要 —— 测试直接比对两半路径与行为
|
|
3117
|
+
draftLockPath, readDraftLockSync, writeDraftLockSync, clearDraftLockSync, draftLockSummaryFor,
|
|
2984
3118
|
// 卡组层(0.10.0):卡库快照 + 卡组作用域 —— 卡组数据的唯一入口,两半共用 lib/group-layer.js 的纯组装
|
|
2985
3119
|
readLibrarySnapshot, groupScope,
|
|
2986
3120
|
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|