@riemannre3/dsh-roleplay 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RiemannRe3
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # DSH RolePlay
2
+
3
+ > 在 DeepSeek Harness 的原生 Session 里游玩 Tavern 角色卡。
4
+
5
+ `@riemannre3/dsh-roleplay` 是一个 DSH Web 插件,支持导入 Tavern Character Card V2/V3,并在原生 Conversation 中开始游玩。
6
+
7
+ ![DSH RolePlay 中的 Tavern 卡片与原生 Conversation](demo.png)
8
+
9
+ ## 环境要求
10
+
11
+ | 组件 | 最低已验证版本 | 说明 |
12
+ | --- | --- | --- |
13
+ | Node.js | `22.19.0` | 同时验证通过 `24.13.1`;推荐使用当前 Node.js 24 LTS |
14
+ | DeepSeek Harness | `0.1.1-rc.2` | 插件当前锁定并验证的 DSH 基线 |
15
+
16
+ 不建议使用更早的 DSH 版本。它们缺少本插件依赖的 Web、Session 或插件运行时接口。
17
+
18
+ ## Windows 安装
19
+
20
+ DSH RolePlay 直接从 npm Registry 安装,不需要先下载 GitHub Release 或 `.tgz` 文件。
21
+
22
+ 先安装固定版本的 DSH:
23
+
24
+ ```powershell
25
+ npm install --global @deepseek-ai/dsh@0.1.1-rc.2
26
+ ```
27
+
28
+ 让 DSH 从 npm 安装 DSH RolePlay、加入标准 Web Profile,然后启动 Web:
29
+
30
+ ```powershell
31
+ dsh plugin --profile web add @riemannre3/dsh-roleplay
32
+ dsh web
33
+ ```
34
+
35
+ `dsh plugin add` 会调用 DSH 自己的包管理器完成 npm 下载和 Profile 注册。不要改成普通的 `npm install @riemannre3/dsh-roleplay`:普通 npm 安装只会下载依赖,不会把插件加入 DSH Web Profile。
36
+
37
+ 打开终端中显示的本机地址(默认是 <http://127.0.0.1:3080>),在“设置 → 模型”中配置模型和 API Key,再进入“酒馆”导入 PNG/JSON 角色卡。
38
+
39
+ 停止 DSH 时,回到运行 `dsh web` 的终端并按 `Ctrl+C`。
40
+
41
+ 更新插件时,停止 DSH 后重新运行同一条安装命令,再启动 Web:
42
+
43
+ ```powershell
44
+ dsh plugin --profile web add @riemannre3/dsh-roleplay
45
+ dsh web
46
+ ```
47
+
48
+ ## 临时关闭与重新开启
49
+
50
+ 打开“设置 → 插件 → 插件配置”,展开 **DSH RolePlay**,点击右侧复选框即可关闭或重新开启。
51
+
52
+ ![在 DSH 插件配置中关闭或开启 DSH RolePlay](plugin-settings.png)
53
+
54
+ 关闭是可逆的运行时卸载:酒馆侧栏、对话适配、输入区扩展、Host 路由、事件监听和运行时资源会被移除;角色卡与 Session 数据仍保存在当前 DSH Web Profile 中。重新开启后,插件会恢复这些界面和运行时能力。
55
+
56
+ ## 从 Web Profile 移除
57
+
58
+ 先停止正在运行的 DSH,再执行:
59
+
60
+ ```powershell
61
+ dsh plugin --profile web remove @riemannre3/dsh-roleplay
62
+ ```
63
+
64
+ 这会从 Web Profile 移除 npm 包,不会主动删除插件已经保存的角色卡和 Session。重新安装到同一个 Profile 后仍可恢复;不要为了卸载插件删除整个 DSH 数据目录。
65
+
66
+ GitHub Release 中的 `.tgz` 只用于离线安装或 npm Registry 暂时不可用时的故障备用,不是推荐安装方式。
67
+
68
+ ## 从源码构建
69
+
70
+ 仅在开发或本地修改插件时需要源码构建:
71
+
72
+ ```powershell
73
+ git clone https://github.com/RiemannRe3/DSH-RolePlay.git
74
+ cd DSH-RolePlay
75
+ npm ci --legacy-peer-deps
76
+ npm run build
77
+ dsh plugin --profile web add .
78
+ dsh web
79
+ ```
80
+
81
+ `npm run build` 会编译 Host 代码,并生成 DSH Web 加载的单文件 Client Bundle。修改源码后,重新执行构建和 `dsh plugin --profile web add .`,再重启 DSH。
82
+
83
+ ## 问题反馈
84
+
85
+ 项目源码与问题反馈:[RiemannRe3/DSH-RolePlay](https://github.com/RiemannRe3/DSH-RolePlay)。请勿在 Issue 中公开 API Key、Token、私有角色卡或完整 Session Log。
86
+
87
+ ## License
88
+
89
+ 第一方代码使用 [MIT License](LICENSE)。
@@ -0,0 +1,4 @@
1
+ # DSH Re3 RP host runtime plus the accepted #101 sidebar adapter.
2
+ - insert:
3
+ - id: dsh-roleplay
4
+ name: '@riemannre3/dsh-roleplay'
package/demo.png ADDED
Binary file
@@ -0,0 +1,68 @@
1
+ function invalid(message) {
2
+ return Object.assign(new Error(message), { code: "invalid_action" });
3
+ }
4
+ export function normalizeTavernHelperGenerateConfig(value) {
5
+ if (value !== undefined && (typeof value !== "object" || value === null || Array.isArray(value)))
6
+ throw invalid("TavernHelper.generate 参数必须是对象");
7
+ const input = (value ?? {});
8
+ const rawInjects = input.injects === undefined ? [] : input.injects;
9
+ if (!Array.isArray(rawInjects) || rawInjects.length > 64)
10
+ throw invalid("TavernHelper.generate injects 必须是不超过 64 项的数组");
11
+ let totalBytes = 0;
12
+ const encoder = new TextEncoder();
13
+ const injects = rawInjects.map((raw, index) => {
14
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
15
+ throw invalid(`TavernHelper.generate injects[${index}] 必须是对象`);
16
+ const item = raw;
17
+ const role = item.role ?? "user";
18
+ if (role !== "system" && role !== "user" && role !== "assistant")
19
+ throw invalid(`TavernHelper.generate injects[${index}].role 无效`);
20
+ if (typeof item.content !== "string" || item.content.trim().length === 0)
21
+ throw invalid(`TavernHelper.generate injects[${index}].content 不能为空`);
22
+ totalBytes += encoder.encode(item.content).byteLength;
23
+ if (totalBytes > 512 * 1024)
24
+ throw invalid("TavernHelper.generate 注入内容总计不能超过 512 KiB");
25
+ const position = item.position ?? "in_chat";
26
+ if (position !== "before_prompt" && position !== "in_chat")
27
+ throw invalid(`TavernHelper.generate injects[${index}].position 无效`);
28
+ const rawDepth = item.depth ?? 0;
29
+ if (typeof rawDepth !== "number" || !Number.isFinite(rawDepth) || rawDepth < 0 || rawDepth > 10_000)
30
+ throw invalid(`TavernHelper.generate injects[${index}].depth 无效`);
31
+ return { role, content: item.content, position, depth: Math.floor(rawDepth), shouldScan: item.should_scan !== false };
32
+ });
33
+ const rawTimeout = input.timeout_ms ?? input.timeout ?? 120_000;
34
+ if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout))
35
+ throw invalid("TavernHelper.generate timeout 必须是毫秒数");
36
+ return {
37
+ injects,
38
+ shouldStream: input.should_stream === true,
39
+ timeoutMs: Math.max(1_000, Math.min(120_000, Math.floor(rawTimeout))),
40
+ };
41
+ }
42
+ export function generateScanText(chat, config) {
43
+ return [...chat.map((message) => message.content), ...config.injects.filter((item) => item.shouldScan).map((item) => item.content)];
44
+ }
45
+ export function applyTavernHelperGenerateInjections(compiled, config) {
46
+ const messages = compiled.messages.map((message) => ({ ...message, source: { ...message.source } }));
47
+ for (const [order, injection] of config.injects.entries()) {
48
+ const depth = injection.position === "before_prompt" ? 0 : injection.depth;
49
+ const index = Math.max(0, messages.length - Math.min(messages.length, depth));
50
+ messages.splice(index, 0, {
51
+ role: injection.role,
52
+ content: injection.content,
53
+ source: { kind: "preset", blockId: "tavern-helper-generate", promptIdentifier: "tavern-helper-generate", position: injection.position, depth: injection.depth, sourceIndex: order },
54
+ });
55
+ }
56
+ return {
57
+ ...compiled,
58
+ messages,
59
+ stats: {
60
+ ...compiled.stats,
61
+ messageCount: messages.length,
62
+ characterCount: messages.reduce((sum, message) => sum + message.content.length, 0),
63
+ estimatedTokens: messages.reduce((sum, message) => sum + Math.ceil(message.content.length / 4) + 4, 0),
64
+ depthInjections: compiled.stats.depthInjections + config.injects.length,
65
+ contextExceeded: compiled.stats.contextTokens !== null && messages.reduce((sum, message) => sum + Math.ceil(message.content.length / 4) + 4, 0) > compiled.stats.contextTokens,
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,37 @@
1
+ function storedOrder(value) {
2
+ return Number.isFinite(value.libraryOrder) ? value.libraryOrder : Number.MAX_SAFE_INTEGER;
3
+ }
4
+ export function orderVisibleCards(records) {
5
+ return records
6
+ .map((record, index) => ({ record, index }))
7
+ .filter(({ record }) => record.libraryHidden !== true)
8
+ .sort((left, right) => storedOrder(left.record) - storedOrder(right.record) || left.index - right.index)
9
+ .map(({ record }) => record);
10
+ }
11
+ export function reorderVisibleCards(records, revisionIds) {
12
+ const visible = orderVisibleCards(records);
13
+ const expected = new Set(visible.map((record) => record.revisionId));
14
+ const requested = new Set(revisionIds);
15
+ if (requested.size !== revisionIds.length || requested.size !== expected.size || revisionIds.some((revisionId) => !expected.has(revisionId))) {
16
+ throw new Error("必须提交当前可见卡库的完整卡片顺序");
17
+ }
18
+ const order = new Map(revisionIds.map((revisionId, index) => [revisionId, index]));
19
+ return records.map((record) => record.libraryHidden === true ? record : { ...record, libraryOrder: order.get(record.revisionId) });
20
+ }
21
+ export function hideCardFromLibrary(record) {
22
+ return { ...record, libraryHidden: true };
23
+ }
24
+ export function preserveCardLibraryMetadata(fresh, stored) {
25
+ return {
26
+ ...fresh,
27
+ ...(Number.isFinite(stored.libraryOrder) ? { libraryOrder: stored.libraryOrder } : {}),
28
+ ...(stored.libraryHidden === true ? { libraryHidden: true } : {}),
29
+ };
30
+ }
31
+ export function restoreCardToLibrary(fresh, stored) {
32
+ return {
33
+ ...fresh,
34
+ ...(stored !== undefined && Number.isFinite(stored.libraryOrder) ? { libraryOrder: stored.libraryOrder } : {}),
35
+ libraryHidden: false,
36
+ };
37
+ }
@@ -0,0 +1,363 @@
1
+ import { normalizeWorldbookEntry } from "./worldbook.js";
2
+ import { classifyInitvarSyntax, initializeVariableRuntime } from "./variable-runtime.js";
3
+ import { frontendDefinitionFromExtensions, messageRegexScriptsFromExtensions } from "./frontend-runtime.js";
4
+ export const NORMALIZED_CARD_INDEX_VERSION = 8;
5
+ export const SUPPORTED_CRITICAL_TAVERN_HELPER_APIS = new Set(["generate"]);
6
+ const decoder = new TextDecoder("utf-8", { fatal: false });
7
+ const ROOT_KNOWN = new Set(["spec", "spec_version", "data"]);
8
+ const DATA_KNOWN = new Set(["name", "description", "personality", "scenario", "first_mes", "mes_example", "creator_notes", "system_prompt", "post_history_instructions", "alternate_greetings", "tags", "creator", "character_version", "extensions", "character_book", "group_only_greetings", "nickname", "creator_notes_multilingual", "source", "assets"]);
9
+ const ENTRY_KNOWN = new Set(["id", "uid", "comment", "memo", "content", "enabled", "constant", "keys", "key", "secondary_keys", "keysecondary", "selective", "selectiveLogic", "insertion_order", "order", "position", "role", "outletName", "group", "groupOverride", "groupWeight", "sticky", "cooldown", "delay", "use_regex", "probability", "depth", "scan_depth", "extensions"]);
10
+ const ENTRY_EXTENSION_KNOWN = new Set(["position", "role", "outlet_name", "depth", "scan_depth", "probability", "use_probability", "useProbability", "use_regex", "case_sensitive", "match_whole_words", "group", "group_override", "group_weight", "sticky", "cooldown", "delay", "prevent_recursion", "exclude_recursion", "delay_until_recursion", "selectiveLogic"]);
11
+ function isObject(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function text(value) {
15
+ return typeof value === "string" ? value : "";
16
+ }
17
+ function readU32(bytes, offset) {
18
+ return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
19
+ }
20
+ function crc32(bytes) {
21
+ let crc = 0xffffffff;
22
+ for (const byte of bytes) {
23
+ crc ^= byte;
24
+ for (let index = 0; index < 8; index += 1)
25
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
26
+ }
27
+ return (crc ^ 0xffffffff) >>> 0;
28
+ }
29
+ async function pngMetadata(bytes, inflate) {
30
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
31
+ if (!signature.every((value, index) => bytes[index] === value))
32
+ throw new Error("PNG 文件头不完整");
33
+ let offset = 8;
34
+ let charaMetadata;
35
+ let ccv3Metadata;
36
+ let sawEnd = false;
37
+ while (offset + 12 <= bytes.length) {
38
+ const length = readU32(bytes, offset);
39
+ const end = offset + 12 + length;
40
+ if (end > bytes.length)
41
+ throw new Error("PNG chunk 长度越界");
42
+ const typeBytes = bytes.slice(offset + 4, offset + 8);
43
+ const type = String.fromCharCode(...typeBytes);
44
+ const data = bytes.slice(offset + 8, offset + 8 + length);
45
+ const crcInput = new Uint8Array(4 + data.length);
46
+ crcInput.set(typeBytes);
47
+ crcInput.set(data, 4);
48
+ if (crc32(crcInput) !== readU32(bytes, offset + 8 + length))
49
+ throw new Error(`${type} chunk 校验失败`);
50
+ let keyword = "";
51
+ let value = "";
52
+ if (type === "tEXt") {
53
+ const zero = data.indexOf(0);
54
+ if (zero >= 0) {
55
+ keyword = decoder.decode(data.slice(0, zero));
56
+ value = decoder.decode(data.slice(zero + 1));
57
+ }
58
+ }
59
+ else if (type === "zTXt") {
60
+ const zero = data.indexOf(0);
61
+ if (zero >= 0 && data[zero + 1] === 0) {
62
+ keyword = decoder.decode(data.slice(0, zero));
63
+ value = decoder.decode(inflate(data.slice(zero + 2)));
64
+ }
65
+ }
66
+ else if (type === "iTXt") {
67
+ const keywordEnd = data.indexOf(0);
68
+ if (keywordEnd >= 0 && keywordEnd + 3 <= data.length) {
69
+ keyword = decoder.decode(data.slice(0, keywordEnd));
70
+ const compressed = data[keywordEnd + 1] === 1;
71
+ let cursor = keywordEnd + 3;
72
+ const languageEnd = data.indexOf(0, cursor);
73
+ cursor = languageEnd + 1;
74
+ const translatedEnd = data.indexOf(0, cursor);
75
+ if (languageEnd < 0 || translatedEnd < 0)
76
+ throw new Error("iTXt 元数据不完整");
77
+ const payload = data.slice(translatedEnd + 1);
78
+ value = decoder.decode(compressed ? inflate(payload) : payload);
79
+ }
80
+ }
81
+ if (keyword === "ccv3")
82
+ ccv3Metadata = value;
83
+ if (keyword === "chara")
84
+ charaMetadata = value;
85
+ offset = end;
86
+ if (type === "IEND") {
87
+ sawEnd = true;
88
+ break;
89
+ }
90
+ }
91
+ if (!sawEnd)
92
+ throw new Error("PNG 缺少 IEND 结束标记");
93
+ const metadata = ccv3Metadata ?? charaMetadata;
94
+ if (metadata === undefined)
95
+ throw new Error("PNG 没有 chara/ccv3 角色卡元数据");
96
+ let parsed;
97
+ try {
98
+ const compact = metadata.replace(/\s+/gu, "");
99
+ const binary = atob(compact + "=".repeat((4 - compact.length % 4) % 4));
100
+ parsed = JSON.parse(decoder.decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
101
+ }
102
+ catch {
103
+ parsed = JSON.parse(metadata);
104
+ }
105
+ if (!isObject(parsed))
106
+ throw new Error("角色卡元数据不是对象");
107
+ return parsed;
108
+ }
109
+ export async function sha256(bytes) {
110
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
111
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", buffer));
112
+ return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
113
+ }
114
+ function extensionCapabilities(data, frontendDefinition, messageRegexScripts, tavernHelperScripts) {
115
+ const extensions = isObject(data.extensions) ? data.extensions : {};
116
+ const rows = [];
117
+ const meaningful = (value) => {
118
+ if (value === null || value === undefined || value === false || value === "")
119
+ return false;
120
+ if (Array.isArray(value))
121
+ return value.some(meaningful);
122
+ if (isObject(value))
123
+ return Object.values(value).some(meaningful);
124
+ return true;
125
+ };
126
+ for (const [key, value] of Object.entries(extensions)) {
127
+ if (!meaningful(value))
128
+ continue;
129
+ if (key === "regex_scripts" || key === "regex")
130
+ rows.push(messageRegexScripts.length === 0
131
+ ? { capability: "正则脚本", disposition: "仅保留", evidence: "原件与字段完整保存;没有可安全执行的消息展示 Regex" }
132
+ : { capability: "正则脚本", disposition: "等价替代", evidence: `${messageRegexScripts.length} 条 assistant 消息展示 Regex 在只读投影中执行;正式 Session 消息保持原文` });
133
+ else if (key === "tavern_helper")
134
+ rows.push(tavernHelperScripts.length > 0
135
+ ? { capability: "TavernHelper 外部脚本", disposition: "等价替代", evidence: `${tavernHelperScripts.length} 个启用脚本在当前卡片 Session 的隔离 companion iframe 中运行;宿主交互经 DSH Bridge 提交` }
136
+ : frontendDefinition === undefined
137
+ ? { capability: "TavernHelper 外部脚本", disposition: "仅保留", evidence: "角色脚本原件完整保存;没有可运行的启用 script 内容" }
138
+ : { capability: "TavernHelper 前端适配", disposition: "等价替代", evidence: `不执行原版宿主脚本;${frontendDefinition.caseId} 通过 DSH 窄 Bridge、Host 正式提交和可重建投影运行` });
139
+ else if (["mvu_bundle_required", "synthetic_test_asset", "suite_id", "suite_case_id", "card_id", "runtime_class", "required_capabilities", "frontend_assets", "frontend_entry"].includes(key))
140
+ continue;
141
+ else
142
+ rows.push({ capability: `扩展字段 data.extensions.${key}`, disposition: "仅保留", evidence: "原件完整保存;该扩展不参与纯文字世界书运行时" });
143
+ }
144
+ return rows;
145
+ }
146
+ function tavernHelperScripts(data) {
147
+ const extensions = isObject(data.extensions) ? data.extensions : {};
148
+ const helper = isObject(extensions.tavern_helper) ? extensions.tavern_helper : {};
149
+ const scripts = Array.isArray(helper.scripts) ? helper.scripts : [];
150
+ return scripts.flatMap((value, index) => {
151
+ if (!isObject(value) || value.enabled === false)
152
+ return [];
153
+ const source = text(value.content);
154
+ if (source.trim().length === 0 || (text(value.type).length > 0 && text(value.type) !== "script"))
155
+ return [];
156
+ return [{ id: text(value.id) || `script-${index}`, name: text(value.name) || `脚本 ${index + 1}`, source }];
157
+ });
158
+ }
159
+ export function requiredCriticalTavernHelperApis(value) {
160
+ let serialized = "";
161
+ try {
162
+ serialized = typeof value === "string" ? value : JSON.stringify(value);
163
+ }
164
+ catch {
165
+ return [];
166
+ }
167
+ return /\bTavernHelper\s*\.\s*generate\s*\(/u.test(serialized) ? ["TavernHelper.generate"] : [];
168
+ }
169
+ export function missingCriticalTavernHelperApis(required, supported = SUPPORTED_CRITICAL_TAVERN_HELPER_APIS) {
170
+ return required.filter((api) => !supported.has(api.replace(/^TavernHelper\./u, "")));
171
+ }
172
+ function variableObject(value) {
173
+ if (!isObject(value))
174
+ return {};
175
+ try {
176
+ const clone = JSON.parse(JSON.stringify(value));
177
+ return isObject(clone) ? clone : {};
178
+ }
179
+ catch {
180
+ return {};
181
+ }
182
+ }
183
+ function variableDefinition(data, openings, worldbook) {
184
+ const extensions = isObject(data.extensions) ? data.extensions : {};
185
+ const tavernHelper = isObject(extensions.tavern_helper) ? extensions.tavern_helper : {};
186
+ const scripts = Array.isArray(tavernHelper.scripts) ? tavernHelper.scripts.flatMap((value, index) => {
187
+ if (!isObject(value) || value.enabled === false)
188
+ return [];
189
+ const variables = variableObject(value.data);
190
+ return [{ id: text(value.id) || text(value.name) || `script-${index}`, variables }];
191
+ }) : [];
192
+ // MVU intentionally stores [initvar] records as disabled lorebook entries:
193
+ // they are runtime state seeds, not prompt content. Their disabled flag must
194
+ // keep them out of World Info activation without hiding them from MVU init.
195
+ const worldbookSources = worldbook.filter((entry) => /\[initvar\]/iu.test(entry.comment));
196
+ const openingSources = openings.filter((opening) => /<initvar\b/iu.test(opening.message));
197
+ const initSources = [
198
+ ...worldbookSources.map((entry) => ({ id: `worldbook:${entry.id}`, content: entry.content, location: "worldbook" })),
199
+ ...openingSources.map((opening) => ({ id: `opening:${opening.id}`, content: opening.message, location: "opening" })),
200
+ ];
201
+ const initializationFormats = initSources.map((source) => classifyInitvarSyntax(source.content));
202
+ const serialized = JSON.stringify(data);
203
+ const updateFormats = [];
204
+ if (/_\.(?:set|add|insert|assign|remove|delete|unset|move|replace|delta)\s*\(/iu.test(serialized))
205
+ updateFormats.push("legacy");
206
+ if (/<JSONPatch\b|"op"\s*:\s*"(?:replace|delta|insert|add|remove|move)"/iu.test(serialized))
207
+ updateFormats.push("json-patch");
208
+ const unknownFormats = [
209
+ ...initSources.flatMap((source, index) => initializeVariableRuntime({}, [source]).status === "failed" ? [`initvar:${index}:${initializationFormats[index]}`] : []),
210
+ ...(/<UpdateVariable\b/iu.test(serialized) && updateFormats.length === 0 ? ["update:unknown"] : []),
211
+ ];
212
+ return {
213
+ character: variableObject(tavernHelper.variables),
214
+ scripts,
215
+ worldbookInitvarEntryIds: worldbookSources.map((entry) => entry.id),
216
+ openingInitvarIds: openingSources.map((opening) => opening.id),
217
+ initializationFormats: [...new Set(initializationFormats)],
218
+ updateFormats,
219
+ unknownFormats,
220
+ };
221
+ }
222
+ export async function parseCard(bytes, sourceName, inflate) {
223
+ const revisionId = await sha256(bytes);
224
+ const lower = sourceName.toLocaleLowerCase();
225
+ const root = lower.endsWith(".png") ? await pngMetadata(bytes, inflate) : JSON.parse(decoder.decode(bytes));
226
+ if (!isObject(root))
227
+ throw new Error("JSON 顶层不是对象");
228
+ const data = isObject(root.data) ? root.data : root;
229
+ const spec = text(root.spec) || "chara_card_v2";
230
+ if (spec !== "chara_card_v2" && spec !== "chara_card_v3")
231
+ throw new Error("只接受 Tavern V2/V3 角色卡");
232
+ const first = text(data.first_mes);
233
+ const alternates = Array.isArray(data.alternate_greetings) ? data.alternate_greetings.filter((item) => typeof item === "string") : [];
234
+ const rawOpenings = [...(typeof data.first_mes === "string" ? [first] : []), ...alternates];
235
+ const openings = rawOpenings.map((message, index) => ({ id: `opening-${index}`, label: index === 0 ? "卡片开场" : message.trim().length === 0 ? `备选开场 ${index}(空白)` : `备选开场 ${index}`, message }));
236
+ const book = isObject(data.character_book) ? data.character_book : undefined;
237
+ const rawEntries = book !== undefined && Array.isArray(book.entries) ? book.entries : [];
238
+ const worldbook = rawEntries.map(normalizeWorldbookEntry);
239
+ const variables = variableDefinition(data, openings, worldbook);
240
+ const helperScripts = tavernHelperScripts(data);
241
+ const requiredHelperApis = requiredCriticalTavernHelperApis(data);
242
+ const missingHelperApis = missingCriticalTavernHelperApis(requiredHelperApis);
243
+ const frontendDefinition = frontendDefinitionFromExtensions(isObject(data.extensions) ? data.extensions : undefined);
244
+ const messageRegexScripts = messageRegexScriptsFromExtensions(isObject(data.extensions) ? data.extensions : undefined);
245
+ const unsupportedWorldbookFields = new Set();
246
+ for (const rawEntry of rawEntries) {
247
+ if (!isObject(rawEntry))
248
+ continue;
249
+ for (const key of Object.keys(rawEntry))
250
+ if (!ENTRY_KNOWN.has(key))
251
+ unsupportedWorldbookFields.add(`entry.${key}`);
252
+ if (isObject(rawEntry.extensions))
253
+ for (const key of Object.keys(rawEntry.extensions))
254
+ if (!ENTRY_EXTENSION_KNOWN.has(key))
255
+ unsupportedWorldbookFields.add(`entry.extensions.${key}`);
256
+ }
257
+ const unknownFields = [
258
+ ...(data === root ? [] : Object.keys(root).filter((key) => !ROOT_KNOWN.has(key)).map((key) => `root.${key}`)),
259
+ ...Object.keys(data).filter((key) => !DATA_KNOWN.has(key)).map((key) => `data.${key}`),
260
+ ];
261
+ const compatibilityRows = [
262
+ { capability: "卡片结构", disposition: "完整生效", evidence: `${spec} 已解析为不可变 revision ${revisionId.slice(0, 12)}` },
263
+ { capability: "原始文件", disposition: "完整生效", evidence: `${bytes.byteLength.toLocaleString()} bytes 按 SHA-256 原样保存` },
264
+ { capability: "角色开场", disposition: openings.length > 0 ? "完整生效" : "已丢失", evidence: openings.length > 0 ? `${openings.length} 个开场按原序可选择(含原件中的空白项),选择结果绑定到 DSH 会话` : "没有 first_mes 或 alternate_greetings" },
265
+ { capability: "提示词与预设装配", disposition: "等价替代", evidence: "system_prompt、角色定义、场景、示例对话、聊天历史与 post_history_instructions 按内置 SillyTavern Chat Completion 兼容预设在每次请求前重新装配;暂不导入任意外部预设文件" },
266
+ { capability: "世界书", disposition: "等价替代", evidence: `${worldbook.length} 条已建立可持久化索引;执行常驻、主次关键词、Regex、递归、互斥组、概率、时间效果、Order 与八种文本位置;向量检索、角色过滤与自动化脚本仍仅保留` },
267
+ ...extensionCapabilities(data, frontendDefinition, messageRegexScripts, helperScripts),
268
+ ];
269
+ for (const api of requiredHelperApis)
270
+ compatibilityRows.push(missingHelperApis.includes(api)
271
+ ? { capability: api, disposition: "已丢失", evidence: `卡内脚本把 ${api} 作为关键启动依赖,但当前 Host 没有声明该接口` }
272
+ : { capability: api, disposition: "等价替代", evidence: "由 DSH Host 使用当前 Session 绑定的 provider、model、preset 与上下文生成辅助文本;结果不自动写入正式 Conversation" });
273
+ if (frontendDefinition !== undefined) {
274
+ compatibilityRows.push({
275
+ capability: "卡内前端",
276
+ disposition: "等价替代",
277
+ evidence: `${frontendDefinition.runtimeClass} 使用 DSH ${frontendDefinition.container} 容器;只开放 ${frontendDefinition.requiredCapabilities.join("、") || "只读展示"} 对应的冻结能力`,
278
+ });
279
+ }
280
+ if (variables.worldbookInitvarEntryIds.length > 0 || variables.openingInitvarIds.length > 0 || variables.updateFormats.length > 0) {
281
+ compatibilityRows.push({
282
+ capability: "MVU / TavernHelper 变量",
283
+ disposition: variables.unknownFormats.length === 0 ? "等价替代" : "仅保留",
284
+ evidence: variables.unknownFormats.length === 0
285
+ ? `内置原子运行时识别 ${variables.initializationFormats.join(" / ") || "无 initvar"} 初始化与 ${variables.updateFormats.join(" / ") || "无卡内更新样例"} 更新;不执行外部脚本私有实现`
286
+ : `可识别部分已进入内置运行时;未知格式明确降级:${variables.unknownFormats.join("、")}`,
287
+ });
288
+ }
289
+ if (/<%|%>/u.test(JSON.stringify(data)))
290
+ compatibilityRows.push({
291
+ capability: "EJS Prompt Template",
292
+ disposition: "等价替代",
293
+ evidence: "请求前在隔离 QuickJS Worker 中执行;顶层角色字段失败会阻止请求,可选世界书条目失败会在递归、互斥组和时间效果提交前省略;getvar 运行时缺失路径进入逐条诊断并保留模板自身 fallback;不执行任意宿主脚本",
294
+ });
295
+ if (worldbook.some((entry) => entry.position === "at_depth"))
296
+ compatibilityRows.push({ capability: "世界书 @ Depth", disposition: "等价替代", evidence: "编译器保留目标 Depth 与 System/User/Assistant Role,轨迹展示概念排布;实际 DSH 请求使用单一可替换上下文信封,不重写不可变的既有聊天消息" });
297
+ if (worldbook.some((entry) => entry.probability < 100))
298
+ compatibilityRows.push({ capability: "世界书概率", disposition: "等价替代", evidence: "使用 sessionId 与 entryId 的稳定采样,重启后同一会话保持相同结果" });
299
+ const invalidRegexEntries = worldbook.filter((entry) => entry.useRegex && entry.keys.some((key) => { try {
300
+ new RegExp(key, entry.caseSensitive ? "u" : "iu");
301
+ return false;
302
+ }
303
+ catch {
304
+ return true;
305
+ } }));
306
+ if (invalidRegexEntries.length > 0)
307
+ compatibilityRows.push({ capability: "世界书无效正则键", disposition: "已禁用", evidence: `${invalidRegexEntries.length} 个条目的正则键无法编译,条目原文仍保留;entry id:${invalidRegexEntries.slice(0, 12).map((entry) => entry.id).join("、")}` });
308
+ if (unsupportedWorldbookFields.size > 0)
309
+ compatibilityRows.push({ capability: "世界书未执行字段", disposition: "仅保留", evidence: `原件完整保存;纯文字运行时不解释:${Array.from(unsupportedWorldbookFields).slice(0, 12).join("、")}${unsupportedWorldbookFields.size > 12 ? "…" : ""}` });
310
+ if (Array.isArray(data.group_only_greetings) && data.group_only_greetings.length > 0)
311
+ compatibilityRows.push({ capability: "群组专用开场", disposition: "仅保留", evidence: `${data.group_only_greetings.length} 个 group_only_greetings 保留在原件;#107 只建立单角色会话` });
312
+ const unsupportedMacros = new Set();
313
+ for (const match of JSON.stringify(data).matchAll(/\{\{\s*([a-z_][a-z0-9_-]*)(?::[^{}]*)?\s*\}\}/giu)) {
314
+ const name = match[1].toLocaleLowerCase();
315
+ if (!["user", "char", "get_message_variable", "format_message_variable", "getvar", "setvar", "random", "roll"].includes(name))
316
+ unsupportedMacros.add(name);
317
+ }
318
+ const hasRuntimeMacros = /\{\{\s*(?:get_message_variable|format_message_variable|getvar|setvar|random|roll)\b/iu.test(JSON.stringify(data));
319
+ if (hasRuntimeMacros)
320
+ compatibilityRows.push({ capability: "Tavern 运行时宏", disposition: "等价替代", evidence: "执行消息变量读取/格式化、局部 setvar/getvar,以及按 Session 稳定的 random/roll;变量状态仍由 DSH Storage Domain 持有" });
321
+ if (unsupportedMacros.size > 0)
322
+ compatibilityRows.push({ capability: "Tavern 未建模宏", disposition: "仅保留", evidence: `原件保留且不静默执行:${Array.from(unsupportedMacros).slice(0, 12).join("、")}${unsupportedMacros.size > 12 ? "…" : ""}` });
323
+ if (unknownFields.length > 0)
324
+ compatibilityRows.push({ capability: "未知字段", disposition: "仅保留", evidence: `${unknownFields.length} 个未建模字段留在原件中:${unknownFields.slice(0, 8).join("、")}${unknownFields.length > 8 ? "…" : ""}` });
325
+ const degraded = compatibilityRows.some((row) => row.disposition === "仅保留" || row.disposition === "等价替代" || row.disposition === "已禁用");
326
+ const playability = openings.length === 0 || missingHelperApis.length > 0 ? "blocked" : degraded ? "degraded" : "ready";
327
+ return {
328
+ normalizedIndexVersion: NORMALIZED_CARD_INDEX_VERSION,
329
+ revisionId,
330
+ sourceName,
331
+ sourceFormat: spec,
332
+ importedAt: new Date().toISOString(),
333
+ title: text(data.name).trim() || sourceName.replace(/\.[^.]+$/u, ""),
334
+ creator: text(data.creator).trim(),
335
+ creatorNotes: text(data.creator_notes),
336
+ tags: Array.isArray(data.tags) ? data.tags.filter((value) => typeof value === "string") : [],
337
+ characterVersion: text(data.character_version).trim(),
338
+ description: text(data.description),
339
+ personality: text(data.personality),
340
+ scenario: text(data.scenario),
341
+ messageExample: text(data.mes_example),
342
+ systemPrompt: text(data.system_prompt),
343
+ postHistoryInstructions: text(data.post_history_instructions),
344
+ openings,
345
+ worldbook,
346
+ variableDefinition: variables,
347
+ tavernHelperScripts: helperScripts,
348
+ requiredCriticalTavernHelperApis: requiredHelperApis,
349
+ missingCriticalTavernHelperApis: missingHelperApis,
350
+ messageRegexScripts,
351
+ ...(frontendDefinition === undefined ? {} : { frontendDefinition }),
352
+ unknownFields,
353
+ playability,
354
+ statusText: playability === "ready" ? "可开始" : playability === "degraded" ? "可开始,非必需扩展仅保留" : "暂时不能开始",
355
+ statusDetail: openings.length === 0
356
+ ? "卡片没有可用开场,不能建立首条 assistant 消息。"
357
+ : missingHelperApis.length > 0
358
+ ? `卡内启动路径缺少关键接口:${missingHelperApis.join("、")}。补齐前不能开始 Session。`
359
+ : `${openings.length} 个开场、${worldbook.length} 条世界书已进入 Host 运行时;未执行的扩展逐项列在兼容报告中。`,
360
+ compatibilityRows,
361
+ originalBlob: `blobs/${revisionId}`,
362
+ };
363
+ }