@xia-sc/dsh-cc-studio 0.9.0 → 0.9.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 +7 -5
- package/lib/agent.js +8 -6
- package/lib/client.js +215 -27
- package/lib/index.js +195 -30
- 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.9.
|
|
17
|
+
| 包名 / 版本 | `@xia-sc/dsh-cc-studio` `0.9.1` |
|
|
18
18
|
| 兼容 | DSH `>=0.4.5-rc.4`(含**桌面版**)、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) |
|
|
@@ -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.9.
|
|
45
|
+
5. 重启后可选自检:在「CC 模式」会话里调任意一个 cc_* 工具,返回里带 "build": "0.9.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.9.
|
|
61
|
+
dsh plugin --profile web add @xia-sc/dsh-cc-studio@0.9.1 # 锁定版本
|
|
62
62
|
|
|
63
|
-
# 从 GitHub 源安装(可加 #v0.9.
|
|
63
|
+
# 从 GitHub 源安装(可加 #v0.9.1 / #master 指定 tag / 分支)
|
|
64
64
|
dsh plugin --profile web add github:xia-sc/dsh-cc-studio
|
|
65
65
|
|
|
66
66
|
# 从本地仓库安装(开发 / 调试):装出来是 link:,改 lib/*.js 重启即生效
|
|
@@ -128,7 +128,7 @@ dsh plugin --profile web remove @xia-sc/dsh-cc-studio # 卸载(同时移
|
|
|
128
128
|
| 条目管理 | `cc_update_lorebook_entry` / `cc_delete_lorebook_entries` |
|
|
129
129
|
| 导出与起点 | `cc_preview_export`(体积、估算 token、字段清单、重复内容量)/ `cc_new_card`(破坏性,须用户同意后带 `confirm:true`) |
|
|
130
130
|
| 人物关系(0.8.0) | `cc_get_relations` / `cc_patch_relations` / `cc_render_relations` / `cc_group_check` |
|
|
131
|
-
| 共享世界书(0.9.0) | `cc_get_shared_book` / `cc_patch_shared_book` / `cc_promote_shared_entries` / `cc_apply_shared_book` |
|
|
131
|
+
| 共享世界书(0.9.0) | `cc_get_shared_book` / `cc_patch_shared_book` / `cc_promote_shared_entries` / `cc_apply_shared_book` —— 工坊导出页给两种形态:**CCv3 独立世界书**(`lorebook_v3`)与 **SillyTavern 世界书**(`{entries:{…}}`;ST 只认后者)。导卡时(JSON / PNG / CHARX)自动在导出副本上写 ST 的世界书绑定(`data.extensions.world`),PNG 同时带 `ccv3`(纯 CCv3)与 `chara`(V2 回填)两个块 |
|
|
132
132
|
| 分组管理(0.8.0) | `cc_list_groups` / `cc_create_group` / `cc_rename_group` / `cc_delete_group` / `cc_set_card_group` |
|
|
133
133
|
| 已存库 CRUD | `cc_list_library` / `cc_save_to_library` / `cc_load_from_library` / `cc_delete_from_library` / `cc_rename_in_library` / `cc_get_library_entry` |
|
|
134
134
|
|
|
@@ -185,6 +185,8 @@ node tests/tools-contract.test.mjs # 只跑 CC 工具链契约(97 项
|
|
|
185
185
|
|
|
186
186
|
完整历史见 [CHANGELOG.md](./CHANGELOG.md)。最近几版大多是「另一个 LLM 以工具使用者身份跑回归」挖出来的问题:
|
|
187
187
|
|
|
188
|
+
- `0.9.1` **共享世界书导不进 SillyTavern**:把 CCv3 独立 lorebook(`{spec:"lorebook_v3", data}`)拖进「世界信息 → 导入」只会得到 `Error importing world info: … Bad Request`。根因有两层(对着本地 ST 1.19.0 源码 + 实测复现):服务端 `/api/worldinfo/import` 拿到文件第一句就是 `if (!('entries' in worldContent)) throw` —— 顶层没有 `entries` 一律 **400**;而 ST 客户端 `importWorldInfo()` 只转 Novel / Agnai / Risu,整份 `world-info.js` 里搜不到 `lorebook_v3`(**它不是「换个后缀」能解决的**)。另外 ST 的 `parseDecorators()` 只认 `@@activate` / `@@dont_activate`,其余 `@@` 行会**连同正文一起被丢掉**。修法是多一种形态而非改掉原来那种:共享书页与导出页各多一枚「⬇ SillyTavern 世界书」,导出 `{entries:{"<uid>":{…}}}`,字段照抄 ST 自己的 `convertCharacterBook()`,`@@position` / `@@depth` 翻译成 ST 的 `position` / `depth` 并从正文删掉(`@@activate` 原样留给 ST),文件名 = 书名(ST 用上传文件名当世界名);**CCv3 那份一个字节没动**。实测:同一本真书导出的 ST 文件导入返回 **200 + `{"name":…}`**、ST 落盘、条目 uid 与源 `id` 一致、「年表」落在 `position:1`。测试 647 → 683 项(16 个文件)。**同一版还补齐了 SillyTavern 兼容层**:PNG 现在同时写 `ccv3`(纯 CCv3)与 `chara`(诚实的 V2 投影,按规范摘掉装饰器)两个块,读取器改成**按载荷里的 spec 判并优先 `ccv3`**(此前是「取第一个命中的块」,而 ST 导出的 PNG 是 `chara` 在前,于是把 ST 导出的卡拖进工坊会报「spec 需为 chara_card_v3」);导出 PNG / CHARX / JSON 时若该卡所属分组有共享书,会**只在导出副本上**写 ST 的世界书绑定(`data.extensions.world = 书名`)—— ST 里的顺序是先导世界书、再导卡,绑定就自动接上(卡内私有条目还要点一次 Import Card Lore)。规范同样明文禁止往卡片里加应用私有字段,所以卡片顶层一个 ST 私有键都不加。实测:我们导出的双块 PNG 进 ST 返回 **200**,ST 回读 `data.extensions.world`、卡内 3 条书条目与 V3 独有字段俱在且世界名可解析;ST 落盘的「V2 在前」PNG 我们也能读了。测试 683 → **718 项(17 个文件)**。**同一版还有第三步:V2 卡现在能进工坊了** —— SillyTavern 里绝大多数存量卡是 V2(ST 界面新建的就是 V2 形状,只是 `spec` 被改写成 v3 标签),此前拖 PNG/JSON 进来只会得到「spec 需为 chara_card_v3」。现在 `upgradeCardToV3()` 一份实现管 PNG / CHARX / JSON 三条导入路径(规则照 ST 的 `readFromV2()`:顶层 V1 平铺字段只在 `data` 缺值时补、`creatorcomment`→`creator_notes`、`talkativeness`/`fav`→`data.extensions`、纯 V1 整份包成 `data`),导入返回带 `upgradedFrom`、界面明说「V2 卡已升级为 CCv3」(不静默变换);JSON 导入也收口到宿主的新端点 `cc_importFromJson`(客户端不再自己 parse 卡 JSON)。`cc_setDraft` 故意不升级(它是工坊自己草稿的镜像)。测试 718 → **735 项(17 个文件)**。**需重启 dsh(桌面版重启应用)→ 刷新页面**。
|
|
189
|
+
|
|
188
190
|
- `0.9.0` **「共享世界书」的收口版**:0.8.2 把世界设定从「每张卡一份」挪到了「组上一本」,但**所有读数、判定与质量检查还按「卡里」算** —— 五轮 LLM 侧回归把它逐个补平:四个读数工具(`simulate` / `lint` / `group_check` / `preview_export`)全部拆成「**卡内 / 共享书 / 合计**」三段(`simulate` 此前会把「会命中」报成「不会命中」,实测低估约 3 倍);**提升去重后自动同步草稿**(否则下一次保存会把重复原样写回卡里);`cc_validate` 的「世界书 ≥5 条」改走合计口径(按最佳实践去重过的卡此前**必然判不合格**);重复检测补上「**卡 ↔ 共享书**」一类(`bookDuplicates` / `wouldClean`);`constant` 与 `@@decorator` 的存在性检查两端统一;**共享书自己的逐条质量检查**补上(此前「共享书质量走 `cc_get_shared_book`」只是一句没实现的说明 —— 那条线上只有重复检测)。口径与质量各收口成一个纯函数(`lorebookMetrics` / `auditLoreEntries`),以后改口径只动一处。测试 604 → 647 项(15 个文件)。
|
|
189
191
|
|
|
190
192
|
- `0.8.2` **群聊卡这一整段迭代的正式发版**:关系图谱(`<DSH_HOME>/cc-groups/`,三维 = 方向 × 类型 × 强度;节点分「有独立卡的角色」与「卡内 NPC」)+ `cc_render_relations` 把关系渲染进 `description` 的固定标记区让卡自包含(幂等、可 `dryRun`、空图谱 = 清空);随后补齐**模型侧的分组增删改查**(`cc_list_groups` / `cc_create_group` / `cc_rename_group` / `cc_delete_group` / `cc_set_card_group`)、新增**组级共享世界书**(`<DSH_HOME>/cc-books/`,一组一本、文件本身就是 CCv3 官方 `lorebook_v3` JSON,`cc_promote_shared_entries` 一键把成员卡里逐字重复的条目提升进去并从各卡删除),并把工坊左侧改成**卡片结构导航**(总览 / 角色 / 世界观 / 世界书 / 开场与示例 / 关系与分组 / 共享世界书,每项带三态进度徽章)。工具 19 → 32。修的既有问题:**B4** 工具半保存一次就把卡踢出分组(两半 `makeLibraryEntryMeta` 漂移)、**B5** `mutual` 对等边完全不渲染、**关系视图「关系 · 0」与导航 0 条**(客户端读错宿主返回形状 —— 边在 `group` 里)、**换卡后关系视图不换组**。测试 509 → 604 项(14 个文件)。**需重启 dsh(桌面版重启应用)→ 刷新页面**。
|
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.9.
|
|
15
|
+
const BUILD = "0.9.1";
|
|
16
16
|
const inject = ["tools", "agents"];
|
|
17
17
|
|
|
18
18
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
@@ -2285,11 +2285,13 @@ function apply(ctx){
|
|
|
2285
2285
|
|
|
2286
2286
|
// ———————————————— 组级共享世界书(0.9.0)————————————————
|
|
2287
2287
|
// 一组一份:世界设定(年表/势力/地理/力量/日常)提到共享书,成员卡里只留角色私有条目。
|
|
2288
|
-
//
|
|
2288
|
+
// 导出后有两种形态,各有各的读者(0.9.1 补):CCv3 官方 lorebook_v3 JSON 给认这个规范的客户端;
|
|
2289
|
+
// **SillyTavern 只认它自己的 `{entries:{…}}`**(ST 的 /api/worldinfo/import 首句就查顶层 entries,
|
|
2290
|
+
// 拖 lorebook_v3 进去实测 400)—— 工坊导出页两枚按钮分别对应,别混着说。
|
|
2289
2291
|
|
|
2290
2292
|
tool({
|
|
2291
2293
|
name: "cc_get_shared_book",
|
|
2292
|
-
description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」——
|
|
2294
|
+
description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」—— 那些就是该提升到共享书的。共享书是独立文件,工坊导出页给两种形态:**CCv3 独立世界书**(`{spec:\"lorebook_v3\", data}`,给支持该规范的客户端绑成全局世界书)与 **SillyTavern 世界书**(`{entries:{…}}`)。⚠️ SillyTavern 的导入接口只认后者(它连 `lorebook_v3` 都不认,直接拖前者进去会被判 400 Bad Request)—— 所以「交给客户端」时要说清是哪种,别让用户拿到 CCv3 那份去导 ST。",
|
|
2293
2295
|
parameters: {
|
|
2294
2296
|
type:"object",
|
|
2295
2297
|
properties:{
|
|
@@ -2501,14 +2503,14 @@ function apply(ctx){
|
|
|
2501
2503
|
lorebookTokens: cardTokens + sharedTokens,
|
|
2502
2504
|
lorebookSplit: { card: cardTokens, shared: sharedTokens, total: cardTokens + sharedTokens },
|
|
2503
2505
|
sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: sharedTokens,
|
|
2504
|
-
hint: "
|
|
2506
|
+
hint: "独立一本,工坊导出页可单独导出(CCv3 客户端拿「CCv3 独立世界书」,**SillyTavern 拿「SillyTavern 世界书」**);若客户端不支持全局世界书,可在导出时并入本卡。导卡时(JSON / PNG / CHARX)会**自动在导出副本上**写 `data.extensions.world = <书名>`(ST 的角色↔世界书绑定),PNG 里同时写 `ccv3`(纯 CCv3)与 `chara`(V2 回填)两个块 —— 草稿与库里的卡一个字节都不动" } : null,
|
|
2505
2507
|
exportedFields, extensionsExported: Object.keys(d.extensions || {}),
|
|
2506
2508
|
worldDuplicatedChars: dupChars,
|
|
2507
2509
|
loreCount: entries.length, manualLoreCount: entries.filter(e => !isAutoEntry(e)).length,
|
|
2508
2510
|
message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中本卡世界书 " + cardTokens + " token"
|
|
2509
2511
|
+ (shared.length
|
|
2510
2512
|
? ";**本卡所属组另有共享世界书「" + (String(scope.bookName||"").trim() || "未命名") + "」" + shared.length + " 条 / 约 " + sharedTokens
|
|
2511
|
-
+ " token** —— 客户端实际加载时两者叠加(合计 " + (cardTokens + sharedTokens) + " token
|
|
2513
|
+
+ " token** —— 客户端实际加载时两者叠加(合计 " + (cardTokens + sharedTokens) + " token),可单独导出该文件(**SillyTavern 要用「SillyTavern 世界书」那份**)或导出时并入本卡"
|
|
2512
2514
|
: "")
|
|
2513
2515
|
+ ";五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
|
|
2514
2516
|
}
|
|
@@ -2540,7 +2542,7 @@ function apply(ctx){
|
|
|
2540
2542
|
} else if(wf.loreCount < 5){
|
|
2541
2543
|
// 达标靠的是组里那本共享书(第二轮报告 P0 的正解):**不阻塞**,但必须把「导出时要带上它」说清楚 ——
|
|
2542
2544
|
// 否则用户把这张「卡内只有 3 条」的卡发出去,对方客户端没有共享书就等于世界书缺失。
|
|
2543
|
-
warnings.push("本卡内只有 "+wf.loreCount+" 条世界书,达标靠的是本组共享世界书「"+(wf.sharedBook && String(wf.sharedBook.name||"").trim() ? wf.sharedBook.name : "未命名")+"」的 "+wf.sharedLoreCount+" 条(合计 "+wf.loreTotal+"
|
|
2545
|
+
warnings.push("本卡内只有 "+wf.loreCount+" 条世界书,达标靠的是本组共享世界书「"+(wf.sharedBook && String(wf.sharedBook.name||"").trim() ? wf.sharedBook.name : "未命名")+"」的 "+wf.sharedLoreCount+" 条(合计 "+wf.loreTotal+" 条)。**共享书不会跟着卡走**:导出时请把它一并交给客户端(工坊导出页有两枚按钮 —— 支持 `lorebook_v3` 的客户端拿「CCv3 独立世界书」,**SillyTavern 必须拿「SillyTavern 世界书」**,ST 的导入接口只认 `{entries:{…}}` 形态);对方客户端不支持全局世界书时,在导出页勾选「并入本卡」。导卡时若该组有共享书,会**自动在导出副本上**写上 ST 的世界书绑定(`data.extensions.world = 书名`),PNG 里同时带 `ccv3` 与 `chara` 两个块。");
|
|
2544
2546
|
}
|
|
2545
2547
|
// —— 质量(P1-5:此前 valid=true 只代表「四件套非空」,给用户「卡没问题了」的错觉)——
|
|
2546
2548
|
if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充(缺失时模型只能靠 description 猜场景)");
|
package/lib/client.js
CHANGED
|
@@ -60,7 +60,7 @@ window.__ModuleLoader__.load({
|
|
|
60
60
|
"workshop.rel.dirLabel":"方向",
|
|
61
61
|
"workshop.rel.myCard":"当前卡",
|
|
62
62
|
"book.title":"共享世界书",
|
|
63
|
-
"book.hint":"
|
|
63
|
+
"book.hint":"一组一份:世界设定(年表/势力/地理/力量/日常)放这里,成员卡里只留角色私有条目。导出有两种:CCv3 独立世界书(lorebook_v3,给支持该规范的客户端绑成全局世界书)与 SillyTavern 世界书(ST 的导入接口只认 {entries:{…}} 形态,直接把 CCv3 那份拖进去会报 Bad Request)。",
|
|
64
64
|
"book.noGroup":"共享世界书是按分组存放的:先在右侧「已存角色」里建一个分组,并把成员卡拉进去。",
|
|
65
65
|
"book.empty":"这个分组还没有共享世界书。",
|
|
66
66
|
"book.name":"书名",
|
|
@@ -82,6 +82,7 @@ window.__ModuleLoader__.load({
|
|
|
82
82
|
"book.promoteSyncedDraft":"已同步当前草稿,删掉 {n} 条重复(否则保存时会把它们写回去)",
|
|
83
83
|
"book.exportBook":"导出共享世界书 JSON",
|
|
84
84
|
"book.exported":"已导出 {name}",
|
|
85
|
+
"book.exportedSt":"已导出 SillyTavern 世界书「{name}」—— 在 SillyTavern 里「世界信息 → 导入」选它即可",
|
|
85
86
|
"book.applyCard":"合并进当前卡",
|
|
86
87
|
"book.applyDone":"已往当前卡补了 {n} 条",
|
|
87
88
|
"book.applyNone":"当前卡已包含这本共享书的全部条目。",
|
|
@@ -92,9 +93,11 @@ window.__ModuleLoader__.load({
|
|
|
92
93
|
"book.saved":"已保存(rev {rev})",
|
|
93
94
|
// —— 0.9.0 导出页的共享书块 ——
|
|
94
95
|
"export.book.title":"共享世界书",
|
|
95
|
-
"export.book.hint":"
|
|
96
|
+
"export.book.hint":"当前卡所属分组的共享设定可以单独导出:CCv3 独立世界书给支持 lorebook_v3 的客户端当全局世界书;SillyTavern 世界书是 ST 原生形态({entries:{…}})—— ST 的导入接口只认这一种,拖 CCv3 那份进去会报 Bad Request。ST 里的顺序是**先导世界书、再导卡**:卡会自动带上绑定(data.extensions.world);卡内私有条目还要在 ST 里点一次 Import Card Lore 才生效。",
|
|
97
|
+
"export.stWorldBound":"已写入 SillyTavern 世界书绑定「{name}」",
|
|
96
98
|
"export.book.none":"当前卡不在任何分组里,或这个分组还没有共享世界书。",
|
|
97
|
-
"export.book.button":"⬇
|
|
99
|
+
"export.book.button":"⬇ CCv3 独立世界书",
|
|
100
|
+
"export.book.buttonSt":"⬇ SillyTavern 世界书",
|
|
98
101
|
"export.book.merge":"导出 JSON 时把共享条目并入这张卡(给不支持全局世界书的客户端)",
|
|
99
102
|
"export.book.merged":"(已并入 {n} 条共享条目)",
|
|
100
103
|
"action.expand":"✦ AI 扩写",
|
|
@@ -272,11 +275,10 @@ window.__ModuleLoader__.load({
|
|
|
272
275
|
"store.validateFailed":"校验未通过",
|
|
273
276
|
"store.importedOk":"已导入 {k}:{name} · {n} lore(可在侧边栏 ★ 保存)",
|
|
274
277
|
"store.importedSaved":"已导入并自动保存:{name}",
|
|
275
|
-
"store.
|
|
276
|
-
"store.errSpecMismatch":"spec 需为 chara_card_v3,当前为 {v}",
|
|
277
|
-
"store.errNoData":"缺少 data",
|
|
278
|
+
"store.importedUpgraded":"{v} 卡已升级为 CCv3",
|
|
278
279
|
"store.errImportFailed":"导入失败:{msg}",
|
|
279
280
|
"store.errReadFileFailed":"读取文件失败",
|
|
281
|
+
"store.parsingJson":"正在解析 JSON…",
|
|
280
282
|
"store.parsingPng":"正在解析 PNG…",
|
|
281
283
|
"store.errPngImportFailed":"PNG 导入失败:{msg}",
|
|
282
284
|
"store.errReadPngFailed":"读取 PNG 失败",
|
|
@@ -374,7 +376,7 @@ window.__ModuleLoader__.load({
|
|
|
374
376
|
"workshop.rel.dirLabel":"Direction",
|
|
375
377
|
"workshop.rel.myCard":"current card",
|
|
376
378
|
"book.title":"Shared lorebook",
|
|
377
|
-
"book.hint":"One book per group: put the world setting (timeline / factions / geography / power / daily) here and keep only character-private entries in each member card.
|
|
379
|
+
"book.hint":"One book per group: put the world setting (timeline / factions / geography / power / daily) here and keep only character-private entries in each member card. Two export formats: the CCv3 standalone lorebook (lorebook_v3, for clients that support the spec) and the SillyTavern lorebook (ST's importer only accepts the {entries:{…}} shape — feeding it the CCv3 file answers Bad Request).",
|
|
378
380
|
"book.noGroup":"Shared lorebooks live on a group: create one under “Saved characters” on the right and put the member cards in it.",
|
|
379
381
|
"book.empty":"This group has no shared lorebook yet.",
|
|
380
382
|
"book.name":"Book name",
|
|
@@ -396,6 +398,7 @@ window.__ModuleLoader__.load({
|
|
|
396
398
|
"book.promoteSyncedDraft":"Current draft synced — removed {n} duplicates (saving would have written them back)",
|
|
397
399
|
"book.exportBook":"Export shared lorebook JSON",
|
|
398
400
|
"book.exported":"Exported {name}",
|
|
401
|
+
"book.exportedSt":"Exported the SillyTavern lorebook “{name}” — pick it in SillyTavern via World Info → Import",
|
|
399
402
|
"book.applyCard":"Merge into the current card",
|
|
400
403
|
"book.applyDone":"Added {n} entries to the current card",
|
|
401
404
|
"book.applyNone":"The current card already contains every entry of this book.",
|
|
@@ -405,9 +408,11 @@ window.__ModuleLoader__.load({
|
|
|
405
408
|
"book.refresh":"Refresh",
|
|
406
409
|
"book.saved":"Saved (rev {rev})",
|
|
407
410
|
"export.book.title":"Shared lorebook",
|
|
408
|
-
"export.book.hint":"The shared setting of this card's group can be exported on its own
|
|
411
|
+
"export.book.hint":"The shared setting of this card's group can be exported on its own: the CCv3 standalone lorebook for clients that support lorebook_v3, and the SillyTavern lorebook in ST's native shape ({entries:{…}}) — ST's importer accepts nothing else, so feeding it the CCv3 file answers Bad Request. In ST, import the lorebook first and the card second: the card carries the binding automatically (data.extensions.world); the entries inside the card still need one “Import Card Lore” click.",
|
|
412
|
+
"export.stWorldBound":"SillyTavern world binding written: “{name}”",
|
|
409
413
|
"export.book.none":"This card is in no group, or its group has no shared lorebook yet.",
|
|
410
|
-
"export.book.button":"⬇
|
|
414
|
+
"export.book.button":"⬇ CCv3 standalone lorebook",
|
|
415
|
+
"export.book.buttonSt":"⬇ SillyTavern lorebook",
|
|
411
416
|
"export.book.merge":"Merge the shared entries into this card when exporting JSON (for clients without a global lorebook)",
|
|
412
417
|
"export.book.merged":"({n} shared entries merged in)",
|
|
413
418
|
"action.expand":"AI Expand",
|
|
@@ -585,11 +590,10 @@ window.__ModuleLoader__.load({
|
|
|
585
590
|
"store.validateFailed":"Validation failed",
|
|
586
591
|
"store.importedOk":"Imported {k}: {name} · {n} lore (★ Save in the sidebar)",
|
|
587
592
|
"store.importedSaved":"Imported and auto-saved: {name}",
|
|
588
|
-
"store.
|
|
589
|
-
"store.errSpecMismatch":"spec must be chara_card_v3, got {v}",
|
|
590
|
-
"store.errNoData":"missing data",
|
|
593
|
+
"store.importedUpgraded":"{v} card upgraded to CCv3",
|
|
591
594
|
"store.errImportFailed":"Import failed: {msg}",
|
|
592
595
|
"store.errReadFileFailed":"Failed to read the file",
|
|
596
|
+
"store.parsingJson":"Parsing JSON…",
|
|
593
597
|
"store.parsingPng":"Parsing PNG…",
|
|
594
598
|
"store.errPngImportFailed":"PNG import failed: {msg}",
|
|
595
599
|
"store.errReadPngFailed":"Failed to read the PNG",
|
|
@@ -809,7 +813,11 @@ window.__ModuleLoader__.load({
|
|
|
809
813
|
for(var i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i);
|
|
810
814
|
return bytes;
|
|
811
815
|
}
|
|
812
|
-
|
|
816
|
+
/**
|
|
817
|
+
* 导入结果的统一收尾。`upgradedFrom` 非空 = 这份卡原来是 V2/V1(0.9.1 B2 自动升级成 CCv3),
|
|
818
|
+
* 必须在提示里说出来 —— 静默把一张 V2 卡变成 V3 形状不算「什么都没发生」。
|
|
819
|
+
*/
|
|
820
|
+
function handleImportedDraft(obj, label, upgradedFrom){
|
|
813
821
|
obj.spec_version=obj.spec_version||"3.0";
|
|
814
822
|
obj.data.character_book=obj.data.character_book||{name:"",description:"",scan_depth:4,token_budget:1200,recursive_scanning:false,extensions:{},entries:[]};
|
|
815
823
|
if(!Array.isArray(obj.data.character_book.entries)) obj.data.character_book.entries=[];
|
|
@@ -818,7 +826,9 @@ window.__ModuleLoader__.load({
|
|
|
818
826
|
if(!Array.isArray(obj.data.tags)) obj.data.tags=[];
|
|
819
827
|
if(!Array.isArray(obj.data.assets)) obj.data.assets=[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}];
|
|
820
828
|
setDraftFromHost(obj);
|
|
821
|
-
|
|
829
|
+
var msg=t("store.importedOk",{k:label.trim(), name:(obj.data.name||t("workshop.library.unnamed")), n:(obj.data.character_book.entries.length||0)});
|
|
830
|
+
if(upgradedFrom) msg += " · " + t("store.importedUpgraded",{v:(String(upgradedFrom)==="chara_card_v1"? "V1":"V2")});
|
|
831
|
+
set({currentLibraryId:null, lastMsg: msg});
|
|
822
832
|
setTimeout(pushDraftToHost,80);
|
|
823
833
|
if(state.autoValidate) rpc("validate",{card:obj}).then(function(r){ if(r&&r.ok) set({valid:r.value}); }).catch(function(){});
|
|
824
834
|
if(state.autoSaveOnImport){
|
|
@@ -846,16 +856,22 @@ window.__ModuleLoader__.load({
|
|
|
846
856
|
else isJson=true;
|
|
847
857
|
}
|
|
848
858
|
if(isJson){
|
|
859
|
+
// 0.9.1(B2):JSON 也交给宿主升级 —— 客户端以前自己 parse 且只认 chara_card_v3,
|
|
860
|
+
// 于是 ST 里导出的 V2 卡一张都进不来。现在三条导入路径(JSON/PNG/CHARX)共用宿主那份
|
|
861
|
+
// upgradeCardToV3,客户端只负责把文本递过去、把「已升级」说出来。
|
|
849
862
|
var reader=new FileReader();
|
|
850
863
|
reader.onload=function(){
|
|
851
864
|
try{
|
|
852
865
|
var text=String(reader.result||"");
|
|
853
|
-
var
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
866
|
+
var sid=state.currentSessionId||null;
|
|
867
|
+
var args=sid?{json:text, sessionId:sid}:{json:text};
|
|
868
|
+
set({lastMsg:t("store.parsingJson"), busy:"importJson"});
|
|
869
|
+
rpc("cc_importFromJson", args).then(function(res){
|
|
870
|
+
set({busy:null});
|
|
871
|
+
if(res && res.ok && res.value && res.value.draft){
|
|
872
|
+
handleImportedDraft(res.value.draft, " JSON", res.value.upgradedFrom);
|
|
873
|
+
} else set({lastMsg:t("store.errImportFailed",{msg:(res&&res.error?String(res.error.message):t("common.unknownError"))})});
|
|
874
|
+
}).catch(function(e){ set({busy:null, lastMsg:t("store.errImportFailed",{msg:String(e&&e.message||e)})}); });
|
|
859
875
|
}catch(e){ set({lastMsg:t("store.errImportFailed",{msg:String(e&&e.message||e)})}); }
|
|
860
876
|
};
|
|
861
877
|
reader.onerror=function(){ set({lastMsg:t("store.errReadFileFailed")}); };
|
|
@@ -871,7 +887,7 @@ window.__ModuleLoader__.load({
|
|
|
871
887
|
rpc("cc_importFromPng", args).then(function(res){
|
|
872
888
|
set({busy:null});
|
|
873
889
|
if(res && res.ok && res.value && res.value.draft){
|
|
874
|
-
handleImportedDraft(res.value.draft, " PNG");
|
|
890
|
+
handleImportedDraft(res.value.draft, " PNG", res.value.upgradedFrom);
|
|
875
891
|
} else set({lastMsg:t("store.errPngImportFailed",{msg:(res&&res.error?String(res.error.message):t("common.unknownError"))})});
|
|
876
892
|
}).catch(function(e){ set({busy:null, lastMsg:t("store.errPngImportFailed",{msg:String(e&&e.message||e)})}); });
|
|
877
893
|
}catch(e){ set({lastMsg:t("store.errPngImportFailed",{msg:String(e&&e.message||e)})}); }
|
|
@@ -889,7 +905,7 @@ window.__ModuleLoader__.load({
|
|
|
889
905
|
rpc("cc_importFromCharx", args2).then(function(res){
|
|
890
906
|
set({busy:null});
|
|
891
907
|
if(res && res.ok && res.value && res.value.draft){
|
|
892
|
-
handleImportedDraft(res.value.draft, " CHARX");
|
|
908
|
+
handleImportedDraft(res.value.draft, " CHARX", res.value.upgradedFrom);
|
|
893
909
|
} else set({lastMsg:t("store.errCharxImportFailed",{msg:(res&&res.error?String(res.error.message):t("common.unknownError"))})});
|
|
894
910
|
}).catch(function(e){ set({busy:null, lastMsg:t("store.errCharxImportFailed",{msg:String(e&&e.message||e)})}); });
|
|
895
911
|
}catch(e){ set({lastMsg:t("store.errCharxImportFailed",{msg:String(e&&e.message||e)})}); }
|
|
@@ -907,9 +923,20 @@ window.__ModuleLoader__.load({
|
|
|
907
923
|
var norm=function(e){ return String((e&&e.content)!=null? e.content : "").replace(/\s+/g," ").trim(); };
|
|
908
924
|
var x=norm(a); return !!x && x===norm(b);
|
|
909
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* ST 兼容层(0.9.1):当前分组的共享书名 —— SillyTavern 的「角色↔世界书」绑定就是
|
|
928
|
+
* `data.extensions.world = <世界书文件名>`(`characters.js:391,617`),而我们导出的那本世界书
|
|
929
|
+
* 就叫 `<书名>.json`,所以把书名写进去即可。没有共享书时返回 ""(**不写**绑定,免得留一个悬空的名字)。
|
|
930
|
+
*/
|
|
931
|
+
function stWorldName(){
|
|
932
|
+
var bk=state.book;
|
|
933
|
+
if(!bk || !bk.exists) return "";
|
|
934
|
+
return String((bk.book && bk.book.data && bk.book.data.name) || "").trim();
|
|
935
|
+
}
|
|
910
936
|
/**
|
|
911
937
|
* 导出 JSON。0.9.0:若在导出页勾了「并入共享条目」,则**只在导出的副本上**把该组的共享世界书
|
|
912
938
|
* 里卡里还没有的条目补进去(给不支持全局世界书的客户端兜底)——草稿本身一个字节都不动。
|
|
939
|
+
* 0.9.1:同样只在副本上写 ST 的世界书绑定(`data.extensions.world`)。
|
|
913
940
|
*/
|
|
914
941
|
function exportJSON(){
|
|
915
942
|
var out=state.draft, merged=0;
|
|
@@ -924,13 +951,22 @@ window.__ModuleLoader__.load({
|
|
|
924
951
|
merged=add.length;
|
|
925
952
|
}
|
|
926
953
|
}
|
|
954
|
+
var bound=stWorldName();
|
|
955
|
+
if(bound){
|
|
956
|
+
if(out===state.draft) out=JSON.parse(JSON.stringify(state.draft));
|
|
957
|
+
out.data.extensions=Object.assign({}, out.data.extensions);
|
|
958
|
+
out.data.extensions.world=bound;
|
|
959
|
+
}
|
|
927
960
|
var blob=new Blob([JSON.stringify(out,null,2)],{type:"application/json"});
|
|
928
961
|
var a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=(state.draft.data.name||"card")+".json"; a.click(); setTimeout(function(){ URL.revokeObjectURL(a.href); },800);
|
|
929
|
-
set({lastMsg: merged? t("store.exportedJson") + " " + t("export.book.merged",{n:String(merged)}) : t("store.exportedJson")
|
|
962
|
+
set({lastMsg: (merged? t("store.exportedJson") + " " + t("export.book.merged",{n:String(merged)}) : t("store.exportedJson"))
|
|
963
|
+
+ (bound? " · " + t("export.stWorldBound",{name:bound}) : "")});
|
|
930
964
|
}
|
|
931
965
|
function exportPNG(){
|
|
932
966
|
var sid=state.currentSessionId||null;
|
|
967
|
+
var bound=stWorldName();
|
|
933
968
|
var args=sid?{sessionId:sid}:{};
|
|
969
|
+
if(bound) args.stWorld=bound;
|
|
934
970
|
set({busy:"exportPng", lastMsg:t("store.generatingPng")});
|
|
935
971
|
rpc("cc_exportPng", args).then(function(res){
|
|
936
972
|
set({busy:null});
|
|
@@ -939,7 +975,7 @@ window.__ModuleLoader__.load({
|
|
|
939
975
|
var bytes=b64ToBytes(res.value.b64);
|
|
940
976
|
var blob=new Blob([bytes],{type:"image/png"});
|
|
941
977
|
var a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=res.value.filename||((state.draft.data.name||"card")+".png"); a.click(); setTimeout(function(){ URL.revokeObjectURL(a.href); },800);
|
|
942
|
-
set({lastMsg:t("store.exportedPng",{name:(res.value.filename||"card.png")})});
|
|
978
|
+
set({lastMsg:t("store.exportedPng",{name:(res.value.filename||"card.png")}) + (bound? " · " + t("export.stWorldBound",{name:bound}) : "")});
|
|
943
979
|
}catch(e){ set({lastMsg:t("store.errPngExportFailed",{msg:String(e&&e.message||e)})}); }
|
|
944
980
|
} else set({lastMsg:t("store.errPngExportFailed",{msg:(res&&res.error?String(res.error.message):t("common.unknownError"))})});
|
|
945
981
|
}).catch(function(e){ set({busy:null, lastMsg:t("store.errPngExportFailed",{msg:String(e&&e.message||e)})}); });
|
|
@@ -956,7 +992,9 @@ window.__ModuleLoader__.load({
|
|
|
956
992
|
try{
|
|
957
993
|
var b64=arrayBufferToB64(reader.result);
|
|
958
994
|
var sid=state.currentSessionId||null;
|
|
995
|
+
var bound=stWorldName();
|
|
959
996
|
var args=sid?{sessionId:sid, imageB64:b64}:{imageB64:b64};
|
|
997
|
+
if(bound) args.stWorld=bound;
|
|
960
998
|
set({busy:"exportPng", lastMsg:t("store.writingPng",{name:f.name})});
|
|
961
999
|
rpc("cc_exportPng", args).then(function(res){
|
|
962
1000
|
set({busy:null});
|
|
@@ -965,7 +1003,7 @@ window.__ModuleLoader__.load({
|
|
|
965
1003
|
var bytes=b64ToBytes(res.value.b64);
|
|
966
1004
|
var blob=new Blob([bytes],{type:"image/png"});
|
|
967
1005
|
var a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=res.value.filename||((state.draft.data.name||"card")+".png"); a.click(); setTimeout(function(){ URL.revokeObjectURL(a.href); },800);
|
|
968
|
-
set({lastMsg:t("store.wrotePng",{name:(res.value.filename||f.name)})});
|
|
1006
|
+
set({lastMsg:t("store.wrotePng",{name:(res.value.filename||f.name)}) + (bound? " · " + t("export.stWorldBound",{name:bound}) : "")});
|
|
969
1007
|
}catch(e){ set({lastMsg:t("store.errWritePngFailed",{msg:String(e&&e.message||e)})}); }
|
|
970
1008
|
} else set({lastMsg:t("store.errWritePngFailed",{msg:(res&&res.error?String(res.error.message):t("common.unknownError"))})});
|
|
971
1009
|
}).catch(function(e){ set({busy:null, lastMsg:t("store.errWritePngFailed",{msg:String(e&&e.message||e)})}); });
|
|
@@ -979,7 +1017,9 @@ window.__ModuleLoader__.load({
|
|
|
979
1017
|
}
|
|
980
1018
|
function exportCharx(){
|
|
981
1019
|
var sid=state.currentSessionId||null;
|
|
1020
|
+
var bound=stWorldName();
|
|
982
1021
|
var args=sid?{sessionId:sid}:{};
|
|
1022
|
+
if(bound) args.stWorld=bound;
|
|
983
1023
|
set({busy:"exportCharx", lastMsg:t("store.generatingCharx")});
|
|
984
1024
|
rpc("cc_exportCharx", args).then(function(res){
|
|
985
1025
|
set({busy:null});
|
|
@@ -988,7 +1028,7 @@ window.__ModuleLoader__.load({
|
|
|
988
1028
|
var bytes2=b64ToBytes(res.value.b64);
|
|
989
1029
|
var blob2=new Blob([bytes2],{type:"application/zip"});
|
|
990
1030
|
var a2=document.createElement("a"); a2.href=URL.createObjectURL(blob2); a2.download=res.value.filename||((state.draft.data.name||"card")+".charx"); a2.click(); setTimeout(function(){ URL.revokeObjectURL(a2.href); },800);
|
|
991
|
-
set({lastMsg:t("store.exportedCharx",{name:(res.value.filename||"card.charx")})});
|
|
1031
|
+
set({lastMsg:t("store.exportedCharx",{name:(res.value.filename||"card.charx")}) + (bound? " · " + t("export.stWorldBound",{name:bound}) : "")});
|
|
992
1032
|
}catch(e){ set({lastMsg:t("store.errCharxExportFailed",{msg:String(e&&e.message||e)})}); }
|
|
993
1033
|
} else set({lastMsg:t("store.errCharxExportFailed",{msg:(res&&res.error?String(res.error.message):t("common.unknownError"))})});
|
|
994
1034
|
}).catch(function(e){ set({busy:null, lastMsg:t("store.errCharxExportFailed",{msg:String(e&&e.message||e)})}); });
|
|
@@ -1183,6 +1223,152 @@ window.__ModuleLoader__.load({
|
|
|
1183
1223
|
set({lastMsg: t("book.exported",{name:String(b.data.name||"")})});
|
|
1184
1224
|
}catch(e){ set({lastMsg:String(e && e.message || e)}); }
|
|
1185
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* 共享世界书 → **SillyTavern 原生世界书**(`{ entries: { "<uid>": {...} } }`)。
|
|
1228
|
+
*
|
|
1229
|
+
* 为什么要另出一份文件(0.9.1,用户实机报告):ST 服务端 `/api/worldinfo/import` 拿到文件后
|
|
1230
|
+
* 第一件事就是 `if (!('entries' in worldContent)) throw`(SillyTavern `src/endpoints/worldinfo.js`),
|
|
1231
|
+
* 而 CCv3 独立 lorebook 的顶层是 `{spec, data}` —— 于是用户把 `.lorebook.json` 拖进 ST,看到的是
|
|
1232
|
+
* `Error importing world info: … Bad Request`。ST 客户端那条 `importWorldInfo()` 也只转
|
|
1233
|
+
* Novel / Agnai / Risu 三种格式,1.19.0 的整份 `world-info.js` 里**搜不到 `lorebook_v3`** ——
|
|
1234
|
+
* 所以这不是「换个后缀」能解决的,是规范本身 ST 不认,必须按它的形态另导一份。
|
|
1235
|
+
*
|
|
1236
|
+
* 条目映射照抄 ST 自己的 `convertCharacterBook()`(ST 唯一权威的 CC 书转换器,1.19.0):
|
|
1237
|
+
* uid←id、key←keys、keysecondary←secondary_keys、comment←comment/name、content←content、
|
|
1238
|
+
* order←insertion_order、disable←!enabled、addMemo←有没有标题,其余可调项一律从
|
|
1239
|
+
* `extensions.*` 取(`cc_auto` / `cc_world_ref` 也随 `extensions` 原样带过去 —— ST 的条目本来
|
|
1240
|
+
* 就有这个自由字段)。
|
|
1241
|
+
*
|
|
1242
|
+
* 唯一 ST 自己做不到、必须我们补的是 **`@@position` / `@@depth` 装饰器**:ST 的
|
|
1243
|
+
* `parseDecorators()` 只认 `@@activate` / `@@dont_activate`(`KNOWN_DECORATORS`),其余 `@@` 行
|
|
1244
|
+
* **连同正文一起被丢掉**(它从第一个非 `@@` 行开始截取正文)—— 不翻译的话,自动生成的 5 条会
|
|
1245
|
+
* 全部落在默认插入位置、装饰器静默消失。所以这两类翻译成 ST 字段并删掉该行;`@@activate`
|
|
1246
|
+
* 留给 ST 自己认(它本来就有这个语义,硬翻成 constant 只是多一层猜测)。
|
|
1247
|
+
*/
|
|
1248
|
+
var ST_ENTRY_TEMPLATE = { key:[], keysecondary:[], comment:"", content:"", constant:false, vectorized:false, selective:true, selectiveLogic:0, addMemo:false, order:100, position:0, disable:false, ignoreBudget:false, excludeRecursion:false, preventRecursion:false, matchPersonaDescription:false, matchCharacterDescription:false, matchCharacterPersonality:false, matchCharacterDepthPrompt:false, matchScenario:false, matchCreatorNotes:false, delayUntilRecursion:0, probability:100, useProbability:true, depth:4, outletName:"", group:"", groupOverride:false, groupWeight:100, scanDepth:null, caseSensitive:null, matchWholeWords:null, useGroupScoring:null, automationId:"", role:0, sticky:null, cooldown:null, delay:null, triggers:[] };
|
|
1249
|
+
/** ST 的 `world_info_position`:before=0 / after=1 / ANTop=2 / ANBottom=3 / atDepth=4 / EMTop=5 / EMBottom=6。 */
|
|
1250
|
+
var ST_POSITION = { before:0, before_char:0, before_desc:0, after:1, after_char:1, after_desc:1, an_top:2, antop:2, an_bottom:3, anbottom:3, at_depth:4, atdepth:4, em_top:5, emtop:5, em_bottom:6, embottom:6 };
|
|
1251
|
+
/** ST 的 `??` 语义:`extensions.<key>` 优先,缺省才用默认值(与 convertCharacterBook 逐行对应)。 */
|
|
1252
|
+
function stExt(ext, key, dflt){ var v = ext[key]; return (v === undefined || v === null)? dflt : v; }
|
|
1253
|
+
/**
|
|
1254
|
+
* 解析正文**开头连续**的 `@@` 行(与 ST 的 `parseDecorators` 同构:只认开头那一段)。
|
|
1255
|
+
* 只翻译 ST 认不出的 `@@position` / `@@depth`;`@@activate` / `@@dont_activate` 与未知写法
|
|
1256
|
+
* 原样留下 —— ST 或者自己有语义、或者自己会丢,我们不猜。
|
|
1257
|
+
*/
|
|
1258
|
+
function unfurlBookDecorators(content){
|
|
1259
|
+
var text = String(content == null? "" : content);
|
|
1260
|
+
var lines = text.split("\n");
|
|
1261
|
+
var drop = [], pos = null, depth = null;
|
|
1262
|
+
for(var i=0;i<lines.length;i++){
|
|
1263
|
+
var line = lines[i];
|
|
1264
|
+
if(line.slice(0,2) !== "@@") break; // 装饰器区到头
|
|
1265
|
+
if(line.slice(0,3) === "@@@") break; // ST 的转义写法:后面全当原文,不碰
|
|
1266
|
+
var m = /^@@([a-z_]+)(?:\s+(.*))?$/.exec(line.trim());
|
|
1267
|
+
if(!m) break; // 语法可疑:整段交给 ST 自己处理
|
|
1268
|
+
var name = m[1], arg = String(m[2] == null? "" : m[2]).trim();
|
|
1269
|
+
if(name === "position"){
|
|
1270
|
+
// ST 没有「scenario」之类插入点;ST 自己的 convertCharacterBook 对除 before_char 之外的
|
|
1271
|
+
// 一律当 after —— 认不出的值就跟它的回退走,不自己发明位置。
|
|
1272
|
+
var p = ST_POSITION[arg.toLowerCase().replace(/[\s-]+/g,"_")];
|
|
1273
|
+
pos = (typeof p === "number")? p : 1;
|
|
1274
|
+
drop.push(i);
|
|
1275
|
+
} else if(name === "depth"){
|
|
1276
|
+
var n = parseInt(arg, 10);
|
|
1277
|
+
if(isFinite(n) && n >= 0){ depth = n; pos = 4; drop.push(i); } // atDepth
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if(!drop.length) return { position:null, depth:null, content:text };
|
|
1281
|
+
var kept = lines.filter(function(_, i){ return drop.indexOf(i) < 0; });
|
|
1282
|
+
while(kept.length && !String(kept[0]).trim()) kept.shift(); // 删掉装饰器后留下的空行
|
|
1283
|
+
return { position:pos, depth:depth, content: kept.join("\n") };
|
|
1284
|
+
}
|
|
1285
|
+
/** 单条共享书条目 → ST 条目(字段映射与优先级见上面那段注释)。 */
|
|
1286
|
+
function bookEntryToSt(entry, index){
|
|
1287
|
+
var e = entry || {};
|
|
1288
|
+
var ext = (e.extensions && typeof e.extensions === "object")? e.extensions : {};
|
|
1289
|
+
var dec = unfurlBookDecorators(e.content);
|
|
1290
|
+
var title = String(e.comment || e.name || "");
|
|
1291
|
+
var pos = dec.position;
|
|
1292
|
+
if(pos === null) pos = (typeof ext.position === "number")? ext.position : null; // ST 自己的位置偏好
|
|
1293
|
+
if(pos === null) pos = (String(e.position || "") === "before_char")? 0 : 1; // ST convertCharacterBook 的判据
|
|
1294
|
+
var id = (e.id === undefined || e.id === null || e.id === "")? index : e.id;
|
|
1295
|
+
var out = Object.assign({}, ST_ENTRY_TEMPLATE, {
|
|
1296
|
+
uid: id,
|
|
1297
|
+
key: Array.isArray(e.keys)? e.keys.map(String) : [],
|
|
1298
|
+
keysecondary: Array.isArray(e.secondary_keys)? e.secondary_keys.map(String) : [],
|
|
1299
|
+
comment: title,
|
|
1300
|
+
content: dec.content,
|
|
1301
|
+
constant: e.constant === true,
|
|
1302
|
+
selective: e.selective === true,
|
|
1303
|
+
selectiveLogic: stExt(ext, "selectiveLogic", 0),
|
|
1304
|
+
addMemo: !!title,
|
|
1305
|
+
order: (typeof e.insertion_order === "number")? e.insertion_order : 100,
|
|
1306
|
+
position: pos,
|
|
1307
|
+
disable: e.enabled === false,
|
|
1308
|
+
ignoreBudget: ext.ignore_budget === true,
|
|
1309
|
+
excludeRecursion: stExt(ext, "exclude_recursion", false),
|
|
1310
|
+
preventRecursion: stExt(ext, "prevent_recursion", false),
|
|
1311
|
+
matchPersonaDescription: stExt(ext, "match_persona_description", false),
|
|
1312
|
+
matchCharacterDescription: stExt(ext, "match_character_description", false),
|
|
1313
|
+
matchCharacterPersonality: stExt(ext, "match_character_personality", false),
|
|
1314
|
+
matchCharacterDepthPrompt: stExt(ext, "match_character_depth_prompt", false),
|
|
1315
|
+
matchScenario: stExt(ext, "match_scenario", false),
|
|
1316
|
+
matchCreatorNotes: stExt(ext, "match_creator_notes", false),
|
|
1317
|
+
delayUntilRecursion: stExt(ext, "delay_until_recursion", 0),
|
|
1318
|
+
probability: stExt(ext, "probability", 100),
|
|
1319
|
+
useProbability: stExt(ext, "useProbability", true),
|
|
1320
|
+
depth: stExt(ext, "depth", 4),
|
|
1321
|
+
outletName: stExt(ext, "outlet_name", ""),
|
|
1322
|
+
group: stExt(ext, "group", ""),
|
|
1323
|
+
groupOverride: stExt(ext, "group_override", false),
|
|
1324
|
+
groupWeight: stExt(ext, "group_weight", 100),
|
|
1325
|
+
scanDepth: stExt(ext, "scan_depth", null),
|
|
1326
|
+
caseSensitive: stExt(ext, "case_sensitive", null),
|
|
1327
|
+
matchWholeWords: stExt(ext, "match_whole_words", null),
|
|
1328
|
+
useGroupScoring: stExt(ext, "use_group_scoring", null),
|
|
1329
|
+
automationId: stExt(ext, "automation_id", ""),
|
|
1330
|
+
role: stExt(ext, "role", 0),
|
|
1331
|
+
vectorized: stExt(ext, "vectorized", false),
|
|
1332
|
+
sticky: stExt(ext, "sticky", null),
|
|
1333
|
+
cooldown: stExt(ext, "cooldown", null),
|
|
1334
|
+
delay: stExt(ext, "delay", null),
|
|
1335
|
+
displayIndex: stExt(ext, "display_index", index),
|
|
1336
|
+
extensions: ext,
|
|
1337
|
+
triggers: Array.isArray(ext.triggers)? ext.triggers.slice() : [],
|
|
1338
|
+
});
|
|
1339
|
+
if(dec.depth !== null && typeof dec.depth === "number") out.depth = dec.depth;
|
|
1340
|
+
return out;
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* 整本书 → ST 世界书。顶层只留 `entries`:ST 的世界文件就是这个形状,多一个 `spec` 就 400;
|
|
1344
|
+
* `data.extensions.cc`(rev / groupId / lastPromote)是插件自己的状态,不往外带。
|
|
1345
|
+
*/
|
|
1346
|
+
function bookToStWorldInfo(data){
|
|
1347
|
+
var d = data || {};
|
|
1348
|
+
var out = { entries: {} };
|
|
1349
|
+
(Array.isArray(d.entries)? d.entries : []).forEach(function(e, i){
|
|
1350
|
+
var st = bookEntryToSt(e, i);
|
|
1351
|
+
out.entries[String(st.uid)] = st;
|
|
1352
|
+
});
|
|
1353
|
+
return out;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* 导出 SillyTavern 世界书。文件名 = ST 里的世界书名(ST 用**上传文件名**建世界),所以这里
|
|
1357
|
+
* 不能再挂 CCv3 那份的 `.lorebook` 后缀,否则导入后书名会变成「锁天司·共享设定.lorebook」。
|
|
1358
|
+
*/
|
|
1359
|
+
function exportBookStFile(){
|
|
1360
|
+
var b = state.book && state.book.book;
|
|
1361
|
+
if(!b) return;
|
|
1362
|
+
try{
|
|
1363
|
+
var name = String((b.data && b.data.name) || "").trim() || "shared-lorebook";
|
|
1364
|
+
var blob = new Blob([JSON.stringify(bookToStWorldInfo(b.data), null, 2)], {type:"application/json"});
|
|
1365
|
+
var a=document.createElement("a"); a.href=URL.createObjectURL(blob);
|
|
1366
|
+
a.download = name.replace(/[\\/:*?"<>|]/g,"_") + ".json";
|
|
1367
|
+
a.click();
|
|
1368
|
+
setTimeout(function(){ URL.revokeObjectURL(a.href); }, 800);
|
|
1369
|
+
set({lastMsg: t("book.exportedSt",{name:name})});
|
|
1370
|
+
}catch(e){ set({lastMsg:String(e && e.message || e)}); }
|
|
1371
|
+
}
|
|
1186
1372
|
function setBookMerge(v){ set({bookMerge: !!v}); }
|
|
1187
1373
|
async function createGroup(nameArg){
|
|
1188
1374
|
// 0.8.1:不再依赖 window.prompt(内嵌 webview 会拦掉原生对话框 → 用户看到的是「点了没反应」)。
|
|
@@ -1408,7 +1594,7 @@ window.__ModuleLoader__.load({
|
|
|
1408
1594
|
// 0.4.1:这里原本还有一个 exportLibraryEntry —— 直接下载某张已存角色的 JSON。
|
|
1409
1595
|
// 三个导出入口归一之后,卡片上的「导出」改成切到导出页,导出动作只剩导出页那一套实现,
|
|
1410
1596
|
// 这条旁路就没有调用者了(留着它等于「同一次导出有两套行为」),故删除。
|
|
1411
|
-
return { getSnapshot:getSnapshot, subscribe:subscribe, setCur:setCur, setPanel:setPanel, setSideExpanded:setSideExpanded, setIsCcMode:setIsCcMode, setDraftFromHost:setDraftFromHost, setSessionId:setSessionId, setSlotWarn:setSlotWarn, setAlternateSlots:setAlternateSlots, migrateSlot:migrateSlot, checkIsCcMode:checkIsCcMode, setPollEnabled:setPollEnabled, setPollInterval:setPollInterval, setAutoValidate:setAutoValidate, setAutoSaveOnImport:setAutoSaveOnImport, updateDraft:updateDraft, validate:validate, exportJSON:exportJSON, exportPNG:exportPNG, embedIntoPng:embedIntoPng, exportCharx:exportCharx, importJSON:importJSON, importAny:importAny, setWorldPatch:setWorldPatch, setEditingWorld:setEditingWorld, cancelEditingWorld:cancelEditingWorld, setEditingText:setEditingText, clearEditingText:clearEditingText, cancelEditingText:cancelEditingText, updateEditingText:updateEditingText, addEntry:addEntry, setLibraryQuery:setLibraryQuery, toggleLibraryCollapsed:toggleLibraryCollapsed, refreshLibrary:refreshLibrary, saveCurrentToLibrary:saveCurrentToLibrary, saveAsNewToLibrary:saveAsNewToLibrary, clearCurrentLibraryId:clearCurrentLibraryId, clearDraft:clearDraft, loadFromLibrary:loadFromLibrary, deleteFromLibrary:deleteFromLibrary, renameCurrentInLibrary:renameCurrentInLibrary, setCurrentGroupId:setCurrentGroupId, refreshGroups:refreshGroups, createGroup:createGroup, loadGroup:loadGroup, assignGroup:assignGroup, deleteGroup:deleteGroup, patchRelations:patchRelations, checkGroupNow:checkGroupNow, renderRelationsToCard:renderRelationsToCard, openRelations:openRelations, openRelationsFor:openRelationsFor, loadBook:loadBook, patchBook:patchBook, promoteBook:promoteBook, applyBook:applyBook, exportBookFile:exportBookFile, setBookMerge:setBookMerge };
|
|
1597
|
+
return { getSnapshot:getSnapshot, subscribe:subscribe, setCur:setCur, setPanel:setPanel, setSideExpanded:setSideExpanded, setIsCcMode:setIsCcMode, setDraftFromHost:setDraftFromHost, setSessionId:setSessionId, setSlotWarn:setSlotWarn, setAlternateSlots:setAlternateSlots, migrateSlot:migrateSlot, checkIsCcMode:checkIsCcMode, setPollEnabled:setPollEnabled, setPollInterval:setPollInterval, setAutoValidate:setAutoValidate, setAutoSaveOnImport:setAutoSaveOnImport, updateDraft:updateDraft, validate:validate, exportJSON:exportJSON, exportPNG:exportPNG, embedIntoPng:embedIntoPng, exportCharx:exportCharx, importJSON:importJSON, importAny:importAny, setWorldPatch:setWorldPatch, setEditingWorld:setEditingWorld, cancelEditingWorld:cancelEditingWorld, setEditingText:setEditingText, clearEditingText:clearEditingText, cancelEditingText:cancelEditingText, updateEditingText:updateEditingText, addEntry:addEntry, setLibraryQuery:setLibraryQuery, toggleLibraryCollapsed:toggleLibraryCollapsed, refreshLibrary:refreshLibrary, saveCurrentToLibrary:saveCurrentToLibrary, saveAsNewToLibrary:saveAsNewToLibrary, clearCurrentLibraryId:clearCurrentLibraryId, clearDraft:clearDraft, loadFromLibrary:loadFromLibrary, deleteFromLibrary:deleteFromLibrary, renameCurrentInLibrary:renameCurrentInLibrary, setCurrentGroupId:setCurrentGroupId, refreshGroups:refreshGroups, createGroup:createGroup, loadGroup:loadGroup, assignGroup:assignGroup, deleteGroup:deleteGroup, patchRelations:patchRelations, checkGroupNow:checkGroupNow, renderRelationsToCard:renderRelationsToCard, openRelations:openRelations, openRelationsFor:openRelationsFor, loadBook:loadBook, patchBook:patchBook, promoteBook:promoteBook, applyBook:applyBook, exportBookFile:exportBookFile, exportBookStFile:exportBookStFile, setBookMerge:setBookMerge };
|
|
1412
1598
|
}
|
|
1413
1599
|
|
|
1414
1600
|
var S={
|
|
@@ -1974,6 +2160,7 @@ window.__ModuleLoader__.load({
|
|
|
1974
2160
|
h("div",{style:{fontSize:11, color:"var(--dsw-alias-label-secondary)", marginBottom:10, lineHeight:1.7}}, t("export.book.hint")),
|
|
1975
2161
|
h("div",{style:{display:"flex", gap:8, flexWrap:"wrap"}},
|
|
1976
2162
|
h("button",{type:"button", style:S.btn, onMouseDown:stopFocusSteal, onClick:function(){ store.exportBookFile(); }}, t("export.book.button")),
|
|
2163
|
+
h("button",{type:"button", style:Object.assign({}, S.btn, S.btnGhost), onMouseDown:stopFocusSteal, onClick:function(){ store.exportBookStFile(); }}, t("export.book.buttonSt")),
|
|
1977
2164
|
h("button",{type:"button", style:Object.assign({}, S.btn, S.btnGhost), onMouseDown:stopFocusSteal, disabled: s.bookBusy==="apply", onClick:function(){ store.applyBook(); }}, s.bookBusy==="apply"? "…" : t("book.applyCard")),
|
|
1978
2165
|
h("button",{type:"button", style:Object.assign({}, S.btn, S.btnGhost), onMouseDown:stopFocusSteal, onClick:function(){ store.loadBook(); }}, t("book.refresh"))),
|
|
1979
2166
|
s.lastMsg? h("div",{style:{marginTop:10, fontSize:12, color:"var(--dsw-alias-label-secondary)"}}, s.lastMsg):null) : null
|
|
@@ -2059,6 +2246,7 @@ window.__ModuleLoader__.load({
|
|
|
2059
2246
|
h("div",{style:{fontSize:11, color:"var(--dsw-alias-label-secondary)", margin:"6px 0 8px", lineHeight:1.7}}, t("export.book.hint")),
|
|
2060
2247
|
h("div",{style:{display:"flex", gap:8, flexWrap:"wrap", alignItems:"center"}},
|
|
2061
2248
|
h("button",{type:"button", style:S.btn, onMouseDown:stopFocusSteal, onClick:function(){ store.exportBookFile(); }}, t("export.book.button")),
|
|
2249
|
+
h("button",{type:"button", style:Object.assign({}, S.btn, S.btnGhost), onMouseDown:stopFocusSteal, onClick:function(){ store.exportBookStFile(); }}, t("export.book.buttonSt")),
|
|
2062
2250
|
h("label",{style:{display:"flex", alignItems:"center", gap:6, fontSize:11, color:"var(--dsw-alias-label-secondary)", cursor:"pointer"}},
|
|
2063
2251
|
h("input",{type:"checkbox", checked:!!s.bookMerge, onChange:function(ev){ store.setBookMerge(ev.target.checked); }}),
|
|
2064
2252
|
t("export.book.merge"))));
|
package/lib/index.js
CHANGED
|
@@ -743,11 +743,65 @@ async function readLibraryRaw(id){
|
|
|
743
743
|
throw lastErr || new Error("找不到角色卡: " + String(id));
|
|
744
744
|
}
|
|
745
745
|
// ========== PNG / CHARX 共享 ==========
|
|
746
|
+
/** tags 在 V1/ST 那边可能是逗号分隔的字符串;V2/V3 要数组。 */
|
|
747
|
+
function normTags(v){
|
|
748
|
+
if(Array.isArray(v)) return v.map(function(x){ return String(x); });
|
|
749
|
+
if(typeof v==="string") return v.split(",").map(function(s){ return s.trim(); }).filter(Boolean);
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* V2(乃至 V1)卡 → CCv3 升级(0.9.1 的 B2)。返回 `{card, from}`,`from` 为 null = 本来就是 V3。
|
|
754
|
+
*
|
|
755
|
+
* 为什么做:SillyTavern 里**绝大多数存量卡是 V2**(ST 界面新建的就是 V2 形状,ST 只是把 `spec` 改写成
|
|
756
|
+
* 了 v3 标签),而工坊此前只收 `chara_card_v3` —— 用户把 ST 里那张卡的 PNG/JSON 拖进来,只会得到
|
|
757
|
+
* 一句「spec 需为 chara_card_v3」。升级规则照 ST 自己的 `readFromV2()`(`characters.js:504-556`):
|
|
758
|
+
* 顶层那些 V1 平铺字段并进 `data`(**只在 `data` 里没有该值时**,不覆盖 V2 自己的值),其余不认识
|
|
759
|
+
* 的字段原样留着。`from` 交给调用方,用来**明确告诉用户「这份是升上来的」**(不静默变换)。
|
|
760
|
+
*/
|
|
761
|
+
function upgradeCardToV3(obj){
|
|
762
|
+
if(!obj || typeof obj!=="object") throw new Error("卡必须是对象");
|
|
763
|
+
if(obj.spec==="chara_card_v3" && obj.data && typeof obj.data==="object") return { card: obj, from: null, copied: [] };
|
|
764
|
+
const out=JSON.parse(JSON.stringify(obj));
|
|
765
|
+
const copied=[];
|
|
766
|
+
const hasData=!!(out.data && typeof out.data==="object");
|
|
767
|
+
if(!hasData){
|
|
768
|
+
// 纯 V1:整份包成 data
|
|
769
|
+
const flat={};
|
|
770
|
+
["name","description","personality","scenario","first_mes","mes_example"].forEach(function(k){ if(out[k]!==undefined) flat[k]=out[k]; });
|
|
771
|
+
flat.creator_notes = out.creator_notes!==undefined? out.creator_notes : (out.creatorcomment!==undefined? out.creatorcomment : "");
|
|
772
|
+
flat.system_prompt="";
|
|
773
|
+
flat.post_history_instructions="";
|
|
774
|
+
flat.alternate_greetings=Array.isArray(out.alternate_greetings)? out.alternate_greetings : [];
|
|
775
|
+
flat.tags=normTags(out.tags);
|
|
776
|
+
flat.creator=out.creator!==undefined? out.creator : "";
|
|
777
|
+
flat.character_version=out.character_version!==undefined? out.character_version : "";
|
|
778
|
+
flat.extensions={};
|
|
779
|
+
if(out.talkativeness!==undefined) flat.extensions.talkativeness=out.talkativeness;
|
|
780
|
+
if(out.fav!==undefined) flat.extensions.fav=out.fav===true;
|
|
781
|
+
out.data=flat;
|
|
782
|
+
} else {
|
|
783
|
+
out.data.extensions=(out.data.extensions && typeof out.data.extensions==="object")? out.data.extensions : {};
|
|
784
|
+
["name","description","personality","scenario","first_mes","mes_example"].forEach(function(k){
|
|
785
|
+
if(out.data[k]===undefined && out[k]!==undefined){ out.data[k]=out[k]; copied.push("data."+k); }
|
|
786
|
+
});
|
|
787
|
+
if(out.data.tags===undefined && out.tags!==undefined){ out.data.tags=normTags(out.tags); copied.push("data.tags"); }
|
|
788
|
+
if(out.data.creator_notes===undefined){
|
|
789
|
+
if(out.creator_notes!==undefined){ out.data.creator_notes=out.creator_notes; copied.push("data.creator_notes"); }
|
|
790
|
+
else if(out.creatorcomment!==undefined){ out.data.creator_notes=out.creatorcomment; copied.push("data.creator_notes"); }
|
|
791
|
+
}
|
|
792
|
+
if(out.talkativeness!==undefined && out.data.extensions.talkativeness===undefined){ out.data.extensions.talkativeness=out.talkativeness; copied.push("data.extensions.talkativeness"); }
|
|
793
|
+
if(out.fav!==undefined && out.data.extensions.fav===undefined){ out.data.extensions.fav=out.fav===true; copied.push("data.extensions.fav"); }
|
|
794
|
+
}
|
|
795
|
+
const from=String(out.spec || (hasData? "chara_card_v2" : "chara_card_v1"));
|
|
796
|
+
if(typeof out.data.name!=="string" || !out.data.name.trim()) throw new Error("不是可识别的卡片(没有名称,spec="+from+")");
|
|
797
|
+
out.spec="chara_card_v3";
|
|
798
|
+
out.spec_version="3.0";
|
|
799
|
+
return { card: out, from: from, copied: copied };
|
|
800
|
+
}
|
|
746
801
|
function normalizeDraft(d){
|
|
747
802
|
if(!d || typeof d!=="object") throw new Error("draft 必须是对象");
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if(!d.data || typeof d.data!=="object") throw new Error("缺少 data");
|
|
803
|
+
// V2/V1 一律**升级**而不是拒绝(B2);本来就是 V3 时 up.card 就是原对象
|
|
804
|
+
d=upgradeCardToV3(d).card;
|
|
751
805
|
d.spec_version=d.spec_version||"3.0";
|
|
752
806
|
d.data.character_book=d.data.character_book||{name:"",description:"",scan_depth:4,token_budget:1200,recursive_scanning:false,extensions:{},entries:[]};
|
|
753
807
|
if(!Array.isArray(d.data.character_book.entries)) d.data.character_book.entries=[];
|
|
@@ -777,8 +831,7 @@ function b64DecodeBytes(b64){ return new Uint8Array(Buffer.from(String(b64),"bas
|
|
|
777
831
|
function extractCardFromPngBytes(bytes){
|
|
778
832
|
if(bytes.length<8 || bytes[0]!==0x89 || bytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
|
|
779
833
|
let off=8;
|
|
780
|
-
let
|
|
781
|
-
let lastError=null;
|
|
834
|
+
let v3=null, v2=null, sawCardChunk=false, lastError=null;
|
|
782
835
|
while(off+8 <= bytes.length){
|
|
783
836
|
const len=(bytes[off]<<24)|(bytes[off+1]<<16)|(bytes[off+2]<<8)|bytes[off+3];
|
|
784
837
|
if(len<0 || len> 20*1024*1024) break;
|
|
@@ -789,15 +842,19 @@ function extractCardFromPngBytes(bytes){
|
|
|
789
842
|
try{
|
|
790
843
|
const zero=data.indexOf(0);
|
|
791
844
|
if(zero>=0){
|
|
792
|
-
const
|
|
793
|
-
const text=bytesToStr(data.slice(zero+1));
|
|
794
|
-
const kw=String(keyword).trim().toLowerCase();
|
|
845
|
+
const kw=String(bytesToStr(data.slice(0,zero))).trim().toLowerCase();
|
|
795
846
|
if(kw==="ccv3" || kw==="ccv3_card" || kw==="chara" || kw==="chara_card_v3"){
|
|
796
|
-
|
|
847
|
+
sawCardChunk=true;
|
|
848
|
+
let jsonStr=bytesToStr(data.slice(zero+1)).trim();
|
|
797
849
|
try{ const decoded=bytesToStr(b64DecodeBytes(jsonStr)); if(decoded.trim().startsWith("{")) jsonStr=decoded; }catch{}
|
|
798
850
|
const obj=JSON.parse(jsonStr);
|
|
799
|
-
|
|
800
|
-
|
|
851
|
+
const cand=(obj && obj.spec)? obj : ((obj && obj.data)? {spec:"chara_card_v3",spec_version:"3.0",data:obj} : null);
|
|
852
|
+
// 判据用**载荷里的 spec**,不用块名:规范允许 `chara` 块里放回填用的 V2 对象,但现实里
|
|
853
|
+
// 也有工具把 V3 卡塞进 `chara`(我们自己历史上就写过 `chara_card_v3` 这个块名)。
|
|
854
|
+
if(cand && cand.data){
|
|
855
|
+
if(String(cand.spec)==="chara_card_v3"){ if(!v3) v3=cand; if(v3) break; }
|
|
856
|
+
else if(!v2) v2=cand;
|
|
857
|
+
}
|
|
801
858
|
}
|
|
802
859
|
}
|
|
803
860
|
}catch(e){ lastError=e; }
|
|
@@ -807,33 +864,103 @@ function extractCardFromPngBytes(bytes){
|
|
|
807
864
|
if(type==="IEND") break;
|
|
808
865
|
off+=12+len;
|
|
809
866
|
}
|
|
810
|
-
if
|
|
811
|
-
|
|
867
|
+
// 两个块都在时**必须**取 ccv3 —— 规范原文:「if the application detects both `chara` and `ccv3` chunk,
|
|
868
|
+
// the application *SHOULD* use the `ccv3` chunk」。SillyTavern 自己导出的 PNG 就是 chara 在前、ccv3 在后,
|
|
869
|
+
// 旧实现「取第一个命中的块」于是读到 V2 那份,再把 spec=chara_card_v2 的卡交给 normalizeDraft 直接报错。
|
|
870
|
+
if(v3) return v3;
|
|
871
|
+
// 只有 V2 块(ST 里那张卡的 `chara` 块、或只写过 chara 的老工具):照样交出去,
|
|
872
|
+
// 由 normalizeDraft → upgradeCardToV3 升级成 CCv3(B2:升级而不是拒绝)。
|
|
873
|
+
if(v2) return v2;
|
|
874
|
+
if(!sawCardChunk) throw new Error(lastError? String(lastError.message):"PNG 未找到 ccv3/chara 块(tEXt ccv3),请确认是否为 CCv3 PNG");
|
|
812
875
|
throw new Error("PNG 内卡格式错误");
|
|
813
876
|
}
|
|
814
|
-
|
|
877
|
+
// ========== SillyTavern / V2 兼容层(0.9.1)==========
|
|
878
|
+
// 原则:**底层永远是纯 CCv3**,兼容层只在「导出的那一份副本」上生成(草稿与库里的卡一个字节都不动)。
|
|
879
|
+
// 两个落点,都是规范允许的位置:
|
|
880
|
+
// ① PNG 的 `chara` 块 = 诚实的 CharacterCardV2 投影(见 pngCardChunks 注释);
|
|
881
|
+
// ② `data.extensions.world` = ST 的「角色↔世界书」绑定(ST 只认这个字符串,`characters.js:391,617`)。
|
|
882
|
+
// 规范明文禁止的是「往 CharacterCard 对象里加应用私有字段」(那张卡顶层加 V1 平铺字段就是这一类),所以
|
|
883
|
+
// 我们**不**学 ST 那么干 —— ST 需要的东西全部落在 `extensions` / `chara` 块 / CHARX 资源里。
|
|
884
|
+
/** V2 的 data 字段集(V3 独有的 nickname / creator_notes_multilingual / source / group_only_greetings /
|
|
885
|
+
* assets / creation_date / modification_date 一个都不进 —— 那些字段在 V2 里不存在)。 */
|
|
886
|
+
const V2_DATA_FIELDS=["name","description","personality","scenario","first_mes","mes_example","creator_notes",
|
|
887
|
+
"system_prompt","post_history_instructions","alternate_greetings","character_book","tags","creator","character_version","extensions"];
|
|
888
|
+
/** 摘掉正文**开头连续**的 `@@` 装饰器行(含其后遗留的空行)—— 规范要求回填 V2 时移除全部装饰器。 */
|
|
889
|
+
function stripDecorators(content){
|
|
890
|
+
const lines=String(content==null? "" : content).split("\n");
|
|
891
|
+
let i=0;
|
|
892
|
+
while(i<lines.length && String(lines[i]).slice(0,2)==="@@") i++;
|
|
893
|
+
while(i<lines.length && !String(lines[i]).trim()) i++;
|
|
894
|
+
return lines.slice(i).join("\n");
|
|
895
|
+
}
|
|
896
|
+
/** 整张卡 → CharacterCardV2 对象(PNG 的 `chara` 块用;只做投影,不改调用方传进来的对象)。 */
|
|
897
|
+
function stV2Projection(card){
|
|
898
|
+
const src=(card && typeof card==="object")? card : {};
|
|
899
|
+
const d=(src.data && typeof src.data==="object")? src.data : {};
|
|
900
|
+
const data={};
|
|
901
|
+
V2_DATA_FIELDS.forEach(function(k){
|
|
902
|
+
if(d[k]===undefined) return;
|
|
903
|
+
if(k==="character_book"){
|
|
904
|
+
const book=JSON.parse(JSON.stringify(d[k]));
|
|
905
|
+
if(book && Array.isArray(book.entries)) book.entries.forEach(function(e){ if(e && typeof e==="object") e.content=stripDecorators(e.content); });
|
|
906
|
+
data[k]=book;
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
data[k]=d[k];
|
|
910
|
+
});
|
|
911
|
+
return { spec:"chara_card_v2", spec_version:"2.0", data };
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* 导出副本上写 ST 的世界书绑定。ST 的绑定就是 `data.extensions.world = <世界书文件名>`;
|
|
915
|
+
* 我们的共享书导出成 `<书名>.json`,所以这里直接写书名 —— 用户在 ST 里导完那本世界书,
|
|
916
|
+
* 再导这张卡,绑定就自动接上了(没导书时它只是个悬空字符串,ST 不会报错)。
|
|
917
|
+
* ⚠️ 只在导出副本上做(同「并入本卡」的家规):草稿与库里的卡不许被这个 ST 专有字段污染。
|
|
918
|
+
*/
|
|
919
|
+
function withStWorld(card, world){
|
|
920
|
+
const name=String(world==null? "" : world).trim();
|
|
921
|
+
if(!name) return card;
|
|
922
|
+
const out=JSON.parse(JSON.stringify(card));
|
|
923
|
+
out.data=out.data||{};
|
|
924
|
+
out.data.extensions=Object.assign({}, out.data.extensions);
|
|
925
|
+
out.data.extensions.world=name;
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
function buildTEXtChunk(cardJson, keyword){
|
|
815
929
|
const jsonStr=JSON.stringify(cardJson);
|
|
816
930
|
const b64=Buffer.from(jsonStr,"utf8").toString("base64");
|
|
817
|
-
const
|
|
931
|
+
const kwBytes=strToBytes(keyword||"ccv3");
|
|
818
932
|
const textBytes=strToBytes(b64);
|
|
819
|
-
const chunkData=concatBytes([
|
|
933
|
+
const chunkData=concatBytes([kwBytes, new Uint8Array([0]), textBytes]);
|
|
820
934
|
const typeBytes=strToBytes("tEXt");
|
|
821
935
|
const crc=crc32(concatBytes([typeBytes, chunkData]));
|
|
822
936
|
return concatBytes([u32be(chunkData.length), typeBytes, chunkData, u32be(crc)]);
|
|
823
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* 一张卡要写进 PNG 的两个 tEXt 块:`chara`(V2 投影,给只认 V2 的读取器回填)+ `ccv3`(纯 CCv3,规范里的 MUST)。
|
|
940
|
+
*
|
|
941
|
+
* 规范(SPEC_V3「PNG/APNG」节,0.9.1 实测对齐):
|
|
942
|
+
* ① `ccv3` 块 MUST 存在,值是 CharacterCardV3 对象的 utf-8→base64;
|
|
943
|
+
* ② 应用 MAY 用 `chara` 块回填 V2 对象;两个块都在时读取方 **SHOULD** 用 `ccv3`;
|
|
944
|
+
* ③ 「On backfilling V2, the application SHOULD remove all decorators」—— 所以投影里要摘掉 `@@` 装饰器行。
|
|
945
|
+
* SillyTavern 自己也是两个块都写(`src/character-card-parser.js:15-46`),且 `chara` 在前 —— 这里保持同样顺序。
|
|
946
|
+
* ⚠️ 卡片 JSON 的**顶层**绝不许加 ST 的私有字段(V1 平铺那套):规范同节写着应用 MUST follow the
|
|
947
|
+
* specification、私有数据只能放 `extensions` —— 兼容层只活在 `chara` 这一块与 `extensions.world` 里。
|
|
948
|
+
*/
|
|
949
|
+
function pngCardChunks(cardJson){
|
|
950
|
+
return [buildTEXtChunk(stV2Projection(cardJson), "chara"), buildTEXtChunk(cardJson, "ccv3")];
|
|
951
|
+
}
|
|
824
952
|
function buildPngWithCard(cardJson){
|
|
825
|
-
const chunk=buildTEXtChunk(cardJson);
|
|
826
953
|
const baseB64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
|
|
827
954
|
const base=new Uint8Array(Buffer.from(baseB64,"base64"));
|
|
828
955
|
const iendOff=base.length-12;
|
|
829
956
|
const head=base.slice(0,iendOff);
|
|
830
957
|
const tail=base.slice(iendOff);
|
|
831
|
-
return concatBytes([head,
|
|
958
|
+
return concatBytes([head].concat(pngCardChunks(cardJson), [tail]));
|
|
832
959
|
}
|
|
833
960
|
function embedCardIntoPngBytes(pngBytes, cardJson){
|
|
834
961
|
if(pngBytes.length<8 || pngBytes[0]!==0x89 || pngBytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
|
|
835
962
|
if(pngBytes.length > 15*1024*1024) throw new Error("PNG 过大(>15MB)");
|
|
836
|
-
const
|
|
963
|
+
const newChunks=pngCardChunks(cardJson);
|
|
837
964
|
let off=8;
|
|
838
965
|
const parts=[pngBytes.slice(0,8)];
|
|
839
966
|
let inserted=false;
|
|
@@ -845,7 +972,7 @@ function embedCardIntoPngBytes(pngBytes, cardJson){
|
|
|
845
972
|
const data=pngBytes.slice(off+8, off+8+len);
|
|
846
973
|
const chunkBytes=pngBytes.slice(off, off+12+len);
|
|
847
974
|
if(type==="IEND"){
|
|
848
|
-
parts.push(
|
|
975
|
+
newChunks.forEach(function(c){ parts.push(c); });
|
|
849
976
|
parts.push(chunkBytes);
|
|
850
977
|
inserted=true;
|
|
851
978
|
break;
|
|
@@ -1854,29 +1981,54 @@ function apply(ctx, config){
|
|
|
1854
1981
|
const b64 = args.b64;
|
|
1855
1982
|
if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
|
|
1856
1983
|
if(b64.length > 20*1024*1024) return fail("too-large","PNG 过大(>20MB base64)");
|
|
1857
|
-
let draft;
|
|
1984
|
+
let draft, upgradedFrom = null;
|
|
1858
1985
|
try{
|
|
1859
1986
|
const bytes = b64DecodeBytes(b64);
|
|
1860
1987
|
if(bytes.length > 15*1024*1024) return fail("too-large","PNG 过大(>15MB)");
|
|
1861
1988
|
draft = extractCardFromPngBytes(bytes);
|
|
1989
|
+
// B2:只有 V2 块时也能进(升级而不是拒绝)—— upgradedFrom 让界面把这件事说出来
|
|
1990
|
+
upgradedFrom = upgradeCardToV3(draft).from;
|
|
1862
1991
|
draft = normalizeDraft(draft);
|
|
1863
1992
|
}catch(e){ return fail("parse-failed", String(e.message)); }
|
|
1864
1993
|
const k = draftKeyFrom(ctx, args);
|
|
1865
1994
|
setDraftFor(k, draft);
|
|
1866
|
-
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
|
|
1995
|
+
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
|
|
1996
|
+
}
|
|
1997
|
+
case "cc_importFromJson":{
|
|
1998
|
+
// 工坊拖进来的 .json 走这里(0.9.1,B2):V2/V1 卡**升级**成 CCv3,而不是让界面报「spec 不符」。
|
|
1999
|
+
// 客户端此前自己 parse + 只认 chara_card_v3 —— 于是 ST 里导出的卡一张都进不来。
|
|
2000
|
+
const raw = args.json;
|
|
2001
|
+
let obj = null;
|
|
2002
|
+
try{
|
|
2003
|
+
obj = (raw && typeof raw === "object")? raw : JSON.parse(String(raw==null? "" : raw));
|
|
2004
|
+
}catch(e){ return fail("parse-failed", "不是合法 JSON:"+String(e.message)); }
|
|
2005
|
+
// 自家草稿文件 / 导出包是 `{key, updatedAt, rev, draft}`,先脱一层
|
|
2006
|
+
if(obj && typeof obj==="object" && obj.draft && typeof obj.draft==="object" && !obj.data) obj = obj.draft;
|
|
2007
|
+
let draft, upgradedFrom = null;
|
|
2008
|
+
try{
|
|
2009
|
+
upgradedFrom = upgradeCardToV3(obj).from;
|
|
2010
|
+
draft = normalizeDraft(obj);
|
|
2011
|
+
}catch(e){ return fail("invalid-card", String(e.message)); }
|
|
2012
|
+
const k = draftKeyFrom(ctx, args);
|
|
2013
|
+
setDraftFor(k, draft);
|
|
2014
|
+
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
|
|
1867
2015
|
}
|
|
1868
2016
|
case "cc_exportPng":{
|
|
1869
2017
|
const k = draftKeyFrom(ctx, args);
|
|
1870
2018
|
const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
|
|
1871
2019
|
draft.data.modification_date = nowSec();
|
|
2020
|
+
// ST 兼容层(0.9.1):① 每个 PNG 都写两个块(ccv3 纯 CCv3 + chara 的 V2 投影,见 pngCardChunks);
|
|
2021
|
+
// ② stWorld 非空时,只在**导出副本**上写 `data.extensions.world`(ST 的角色↔世界书绑定)。
|
|
2022
|
+
// 返回给客户端的 draft 仍是「草稿本身」(多一个 ST 专有字段都不带),与「并入本卡」同一条家规。
|
|
2023
|
+
const exportCard = withStWorld(draft, args.stWorld);
|
|
1872
2024
|
try{
|
|
1873
2025
|
let pngBytes;
|
|
1874
2026
|
if(args.imageB64 && typeof args.imageB64==="string" && args.imageB64.length>0){
|
|
1875
2027
|
if(args.imageB64.length > 20*1024*1024) return fail("too-large","底图 PNG 过大(>20MB base64)");
|
|
1876
2028
|
const baseBytes = b64DecodeBytes(args.imageB64);
|
|
1877
|
-
pngBytes = embedCardIntoPngBytes(baseBytes,
|
|
2029
|
+
pngBytes = embedCardIntoPngBytes(baseBytes, exportCard);
|
|
1878
2030
|
} else {
|
|
1879
|
-
pngBytes = buildPngWithCard(
|
|
2031
|
+
pngBytes = buildPngWithCard(exportCard);
|
|
1880
2032
|
}
|
|
1881
2033
|
return ok({ b64: Buffer.from(pngBytes).toString("base64"), filename: (draft.data.name||"card")+".png", draft });
|
|
1882
2034
|
}catch(e){ return fail("build-failed", String(e.message)); }
|
|
@@ -1885,7 +2037,7 @@ function apply(ctx, config){
|
|
|
1885
2037
|
const b64 = args.b64;
|
|
1886
2038
|
if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
|
|
1887
2039
|
if(b64.length > 30*1024*1024) return fail("too-large","CHARX 过大(>30MB base64)");
|
|
1888
|
-
let draft;
|
|
2040
|
+
let draft, upgradedFrom = null;
|
|
1889
2041
|
try{
|
|
1890
2042
|
const bytes = b64DecodeBytes(b64);
|
|
1891
2043
|
if(bytes.length > 22*1024*1024) return fail("too-large","CHARX 过大(>22MB)");
|
|
@@ -1895,20 +2047,21 @@ function apply(ctx, config){
|
|
|
1895
2047
|
if(!cardEntry) return fail("parse-failed","CHARX 未找到 card.json");
|
|
1896
2048
|
const jsonStr = bytesToStr(cardEntry.data);
|
|
1897
2049
|
const obj = JSON.parse(jsonStr);
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
draft = normalizeDraft(draft);
|
|
2050
|
+
upgradedFrom = upgradeCardToV3(obj).from;
|
|
2051
|
+
draft = normalizeDraft(obj);
|
|
1901
2052
|
}catch(e){ return fail("parse-failed", String(e.message)); }
|
|
1902
2053
|
const k = draftKeyFrom(ctx, args);
|
|
1903
2054
|
setDraftFor(k, draft);
|
|
1904
|
-
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
|
|
2055
|
+
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
|
|
1905
2056
|
}
|
|
1906
2057
|
case "cc_exportCharx":{
|
|
1907
2058
|
const k = draftKeyFrom(ctx, args);
|
|
1908
2059
|
const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
|
|
1909
2060
|
draft.data.modification_date = nowSec();
|
|
2061
|
+
// ST 兼容层(0.9.1):只在**导出副本**上写世界书绑定,草稿/库里的卡不动(同「并入本卡」家规)
|
|
2062
|
+
const exportCard = withStWorld(draft, args.stWorld);
|
|
1910
2063
|
try{
|
|
1911
|
-
const zipBytes = buildZipCharx(
|
|
2064
|
+
const zipBytes = buildZipCharx(exportCard);
|
|
1912
2065
|
return ok({ b64: Buffer.from(zipBytes).toString("base64"), filename: (draft.data.name||"card")+".charx", draft });
|
|
1913
2066
|
}catch(e){ return fail("build-failed", String(e.message)); }
|
|
1914
2067
|
}
|
|
@@ -1976,6 +2129,14 @@ function apply(ctx, config){
|
|
|
1976
2129
|
// 存储:<主根>/cc-books/<groupId>.json —— **一本书对一个分组**;文件本身就是可直接发布的
|
|
1977
2130
|
// lorebook_v3 JSON(拷出去别人就能用),插件自己的状态放 `data.extensions.cc`(规范允许)。
|
|
1978
2131
|
// 条目字段与卡内 `character_book.entries` **完全同构** —— 提升/回填都是零转换搬运。
|
|
2132
|
+
//
|
|
2133
|
+
// ⚠️ 0.9.1:这个文件是给**认 CCv3 的客户端**的,**SillyTavern 不认** —— ST 服务端
|
|
2134
|
+
// `/api/worldinfo/import` 拿到文件第一件事就是 `if (!('entries' in worldContent)) throw`
|
|
2135
|
+
// (用户实机:拖 `.lorebook.json` 进去 = `Error importing world info: … Bad Request`;
|
|
2136
|
+
// 1.19.0 的客户端 `importWorldInfo()` 也只转 Novel / Agnai / Risu,整份 `world-info.js`
|
|
2137
|
+
// 搜不到 `lorebook_v3`)。所以工坊导出页另给一枚「SillyTavern 世界书」:ST 原生形态
|
|
2138
|
+
// `{entries:{…}}`,条目字段按 ST 自己的 `convertCharacterBook()` 映射 —— 两枚按钮各有读者,
|
|
2139
|
+
// **别把其中一个说成「就是给客户端的通用格式」**(这正是 0.9.0 那轮写错边界的同一个毛病)。
|
|
1979
2140
|
// ——————————————————————————————————————————————————————————————
|
|
1980
2141
|
const BOOK_SPEC = "lorebook_v3";
|
|
1981
2142
|
const BOOK_MAX_ENTRIES = 200;
|
|
@@ -2212,6 +2373,10 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
|
|
|
2212
2373
|
// 0.9.0 组级共享世界书:落盘 + 纯函数(扫描/提升/回填),两半同构、条目与卡内 character_book 同形
|
|
2213
2374
|
BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
|
|
2214
2375
|
contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
|
|
2376
|
+
// 0.9.1 SillyTavern 兼容层:PNG 双块(chara 的 V2 投影 + 纯 CCv3)、ccv3 优先读取、ST 世界书绑定
|
|
2377
|
+
stV2Projection, stripDecorators, withStWorld, pngCardChunks, buildTEXtChunk, buildPngWithCard, embedCardIntoPngBytes, extractCardFromPngBytes,
|
|
2378
|
+
// 0.9.1 B2:V2/V1 卡升级成 CCv3(PNG/CHARX/JSON 三条导入路径共用这一份实现)
|
|
2379
|
+
upgradeCardToV3, normTags,
|
|
2215
2380
|
REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };
|
|
2216
2381
|
|
|
2217
2382
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xia-sc/dsh-cc-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"presets"
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/data-root.test.mjs",
|
|
41
|
+
"test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/shared-book-st-export.test.mjs && node tests/st-compat.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/data-root.test.mjs",
|
|
42
42
|
"prepublishOnly": "npm test"
|
|
43
43
|
},
|
|
44
44
|
"dsh": {
|