dsh-plugin-tlrp 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2079 @@
1
+ /**
2
+ * tlrp 客户端:TLRP 角色与双态引擎工作室(设置页独立栏目 + 插件页配置卡)。
3
+ *
4
+ * 运行时契约(照 §dsh 0.2.1-rc.5 客户端插件实测源码写死,不猜):
5
+ * - 宿主用 window.__ModuleLoader__.load({ id, factory }) 加载本文件;
6
+ * - apply(ctx) 的 inject 声明 ['slots','locale'],configForms 走可选子作用域,
7
+ * 旧宿主没有该服务时只是不出配置卡,不连累整个插件挂载;
8
+ * - 配置读写全部走 ctx.configForms.get(entryId):它对每个命名空间给一个
9
+ * ConfigFormController,暴露 getSnapshot/subscribe/set/unset/mutate,
10
+ * 写入最终落到 remote.settings.mutate —— 与设置界面、`settings.mutate`
11
+ * 外部接口是同一条通道,不需要自己发明协议;
12
+ * - 页面把三种槽位分开:`settings.section`(设置侧边栏栏目)、
13
+ * `plugins.bundle.config`(键=包名)与 `plugins.row.config`
14
+ * (键=`<包名>#<行 id>`,即插件管理里 bundle 行的配置页)。
15
+ *
16
+ * 之所以要「候选命名空间」:bundle 行会被 Loader 包进 include 组,宿主实际服出的
17
+ * 命名空间是 `include:tlrp`,裸 `tlrp` 只有在没有 include 包装时才存在。
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: "dsh-plugin-tlrp",
21
+ factory: (require) => {
22
+ var module = { exports: {} };
23
+ var exports = module.exports;
24
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
25
+
26
+ const react = require("react");
27
+ const h = react.createElement;
28
+ const { useState, useEffect, useMemo, useRef, useCallback } = react;
29
+
30
+ /** 本插件拥有的字典命名空间。 */
31
+ const NS = "settings.tlrp";
32
+
33
+ /** 宿主实际服出的配置命名空间候选,按优先级排列。 */
34
+ const NAMESPACE_CANDIDATES = ["include:tlrp", "tlrp"];
35
+ /** 本插件在 bundle 清单里的包名。 */
36
+ const BUNDLE_NAME = "dsh-plugin-tlrp";
37
+ /** 本 bundle 在自己 patch 里声明的行 id。 */
38
+ const ROW_ID = "tlrp";
39
+ /** 插件页行配置的槽位键:`<包名>#<行 id>`。 */
40
+ const ROW_CONFIG_KEY = `${BUNDLE_NAME}#${ROW_ID}`;
41
+ /** 设置侧边栏里的栏目 id:取自己的新 id 就与官方条目并列,不覆盖任何人。 */
42
+ const SECTION_ID = "tlrp";
43
+ /** 栏目排序:在 plugins(15) / 记忆类(30~31) 之后,通知(60) 之前。 */
44
+ const SECTION_ORDER = 45;
45
+
46
+ /* ─────────────────────── 角色字段定义 ─────────────────────── */
47
+
48
+ /**
49
+ * 工作室表单的字段表:key 与宿主 Config 的 characterShape 一一对应。
50
+ * `area` 表示多行、`rows` 是默认行高。
51
+ */
52
+ const CHAR_FIELDS = [
53
+ { key: "id", area: false, rows: 1 },
54
+ { key: "name", area: false, rows: 1 },
55
+ { key: "userTitle", area: false, rows: 1 },
56
+ { key: "defaultScene", area: true, rows: 2 },
57
+ { key: "identityLock", area: true, rows: 5 },
58
+ { key: "corePersona", area: true, rows: 5 },
59
+ { key: "appearanceAndSensory", area: true, rows: 5 },
60
+ { key: "actionPresets", area: true, rows: 5 },
61
+ { key: "speechStyle", area: true, rows: 4 },
62
+ { key: "dialogueExamples", area: true, rows: 7 },
63
+ { key: "statusFooterTemplate", area: true, rows: 5 },
64
+ ];
65
+
66
+ /** 新建角色用的空骨架。 */
67
+ const EMPTY_CHARACTER = {
68
+ id: "",
69
+ name: "",
70
+ userTitle: "你",
71
+ identityLock: "",
72
+ corePersona: "",
73
+ defaultScene: "",
74
+ appearanceAndSensory: "",
75
+ actionPresets: "",
76
+ speechStyle: "",
77
+ dialogueExamples: "",
78
+ statusFooterTemplate: "",
79
+ };
80
+
81
+ /** 新建触发器用的空骨架。 */
82
+ const EMPTY_TRIGGER = {
83
+ id: "",
84
+ characterId: "",
85
+ keywords: [],
86
+ injectText: "",
87
+ priority: 50,
88
+ enabled: true,
89
+ };
90
+
91
+ /* ─────────────────────── 形态字段定义 ─────────────────────── */
92
+
93
+ /** 工具策略下拉项:与 core.cjs 的 TOOL_POLICIES 一一对应。 */
94
+ const TOOL_POLICY_OPTIONS = ["allow_all", "whitelist_only", "block_all"];
95
+
96
+ /** 风格层三态:与 lib/schema.cjs 的 modeShape.styleLayer 一一对应。 */
97
+ const STYLE_LAYER_OPTIONS = ["inherit", "on", "dialog", "off"];
98
+
99
+ /** 形态风格层三态的显示文案。 */
100
+ function styleLayerLabel(t, flag) {
101
+ if (flag === "on") return t("styleOn");
102
+ if (flag === "dialog") return t("styleDialog");
103
+ if (flag === "off") return t("styleOff");
104
+ return t("styleInherit");
105
+ }
106
+
107
+ /** 新建形态用的空骨架:字段名与 lib/schema.cjs 的 modeShape 一一对应。 */
108
+ const EMPTY_MODE = {
109
+ id: "",
110
+ name: "",
111
+ commandAliases: [],
112
+ autoKeywords: "",
113
+ autoPriority: 50,
114
+ directivePrompt: "",
115
+ toolPolicy: "allow_all",
116
+ allowedTools: "",
117
+ injectCharacterPersona: true,
118
+ enableStatusFooter: true,
119
+ statusFooterOverride: "",
120
+ bindCharacterId: "",
121
+ isDefaultFallback: false,
122
+ styleLayer: "inherit",
123
+ };
124
+
125
+ /** 形态 id 的合法字符:与宿主注册斜杠指令时用的正则保持一致。 */
126
+ const MODE_ID_RE = /^[A-Za-z0-9_-]+$/;
127
+
128
+ /**
129
+ * UI 兜底形态表:只在配置里还没有 `modes`(新装、或设置快照未到位)时使用。
130
+ * 行为准则与工具策略的真源在宿主 lib/core.cjs 的 DEFAULT_MODES,这里只保证
131
+ * 切换栏与下拉永远有东西可选,不与宿主抢定义权。
132
+ */
133
+ const DEFAULT_MODES = [
134
+ {
135
+ id: "rp",
136
+ name: "少女陪伴态",
137
+ commandAliases: ["/rp", "【流萤】", "【陪伴】"],
138
+ autoKeywords: "喜欢|想你|陪我|晚安|抱抱|撒娇|难受|孤独|害怕|无聊|聊聊|好想|在吗",
139
+ autoPriority: 10,
140
+ directivePrompt: "",
141
+ toolPolicy: "whitelist_only",
142
+ allowedTools: "tlmemory, ask_user_question, schedule",
143
+ injectCharacterPersona: true,
144
+ enableStatusFooter: true,
145
+ statusFooterOverride: "",
146
+ bindCharacterId: "",
147
+ isDefaultFallback: true,
148
+ },
149
+ {
150
+ id: "work",
151
+ name: "战术工作态",
152
+ commandAliases: ["/work", "【战术】"],
153
+ autoKeywords:
154
+ "端口|报错|日志|目录|容器|docker|nginx|ssh|systemctl|配置|脚本|代码|bug|pid|进程|编译|权限|路由",
155
+ autoPriority: 50,
156
+ directivePrompt: "",
157
+ toolPolicy: "allow_all",
158
+ allowedTools: "",
159
+ injectCharacterPersona: false,
160
+ enableStatusFooter: false,
161
+ statusFooterOverride: "",
162
+ bindCharacterId: "",
163
+ isDefaultFallback: false,
164
+ },
165
+ {
166
+ id: "sam",
167
+ name: "萨姆强袭态",
168
+ commandAliases: ["/sam", "【萨姆】", "【机甲】"],
169
+ autoKeywords: "萨姆|变身|机甲|点燃大海|焦土作战|虫群",
170
+ autoPriority: 80,
171
+ directivePrompt: "",
172
+ toolPolicy: "allow_all",
173
+ allowedTools: "",
174
+ injectCharacterPersona: true,
175
+ enableStatusFooter: true,
176
+ statusFooterOverride: "",
177
+ bindCharacterId: "",
178
+ isDefaultFallback: false,
179
+ },
180
+ {
181
+ id: "sleep",
182
+ name: "医疗舱微凉态",
183
+ commandAliases: ["/sleep", "【医疗舱】"],
184
+ autoKeywords: "晚安|睡不着|好累|失眠|医疗舱",
185
+ autoPriority: 60,
186
+ directivePrompt: "",
187
+ toolPolicy: "whitelist_only",
188
+ allowedTools: "tlmemory, ask_user_question, schedule",
189
+ injectCharacterPersona: true,
190
+ enableStatusFooter: true,
191
+ statusFooterOverride: "",
192
+ bindCharacterId: "",
193
+ isDefaultFallback: false,
194
+ },
195
+ ];
196
+
197
+ /** 把形态的别名数组渲染成逗号分隔的编辑文本。 */
198
+ function aliasesToText(aliases) {
199
+ return Array.isArray(aliases) ? aliases.join(", ") : "";
200
+ }
201
+
202
+ /** 把编辑文本拆回别名数组(逗号、顿号与空白都算分隔符)。 */
203
+ function textToAliases(text) {
204
+ return String(text ?? "")
205
+ .split(/[,,、\s]+/)
206
+ .map((word) => word.trim())
207
+ .filter(Boolean);
208
+ }
209
+
210
+ /**
211
+ * 形态归一化:与宿主 normalizeMode 的默认值保持一致,避免表单缺键。
212
+ * @param entry - 配置里的形态对象。
213
+ * @param index - 用于生成兜底 id。
214
+ * @returns 补齐全部字段的形态对象。
215
+ */
216
+ function normalizeModeEntry(entry, index) {
217
+ const source = entry && typeof entry === "object" ? entry : {};
218
+ const id = String(source.id ?? "").trim() || `mode-${index + 1}`;
219
+ const priority = Number(source.autoPriority);
220
+ return {
221
+ ...EMPTY_MODE,
222
+ ...source,
223
+ id,
224
+ name: String(source.name ?? "").trim() || id,
225
+ commandAliases: Array.isArray(source.commandAliases)
226
+ ? source.commandAliases.map((item) => String(item)).filter(Boolean)
227
+ : textToAliases(source.commandAliases),
228
+ autoKeywords: typeof source.autoKeywords === "string" ? source.autoKeywords : "",
229
+ autoPriority: Number.isFinite(priority) ? priority : 50,
230
+ directivePrompt: typeof source.directivePrompt === "string" ? source.directivePrompt : "",
231
+ toolPolicy: TOOL_POLICY_OPTIONS.includes(source.toolPolicy) ? source.toolPolicy : "allow_all",
232
+ allowedTools: typeof source.allowedTools === "string" ? source.allowedTools : "",
233
+ injectCharacterPersona: source.injectCharacterPersona !== false,
234
+ enableStatusFooter: source.enableStatusFooter !== false,
235
+ statusFooterOverride: typeof source.statusFooterOverride === "string" ? source.statusFooterOverride : "",
236
+ bindCharacterId: typeof source.bindCharacterId === "string" ? source.bindCharacterId : "",
237
+ isDefaultFallback: source.isDefaultFallback === true,
238
+ };
239
+ }
240
+
241
+ /** 把配置里的形态数组补齐成可渲染列表;缺失时回落 UI 兜底形态表。 */
242
+ function normalizeModeList(raw) {
243
+ const source = Array.isArray(raw) && raw.length > 0 ? raw : DEFAULT_MODES;
244
+ return source.map((entry, index) => normalizeModeEntry(entry, index));
245
+ }
246
+
247
+ /**
248
+ * 工具策略的可读名。
249
+ * @param t - 字典取词函数。
250
+ * @param policy - allow_all / whitelist_only / block_all。
251
+ * @returns 本地化文案。
252
+ */
253
+ function toolPolicyLabel(t, policy) {
254
+ if (policy === "whitelist_only") return t("policyWhitelist");
255
+ if (policy === "block_all") return t("policyBlockAll");
256
+ return t("policyAllowAll");
257
+ }
258
+
259
+ /* ────────────────────────── 文案 ────────────────────────── */
260
+
261
+ /** 中文文案。 */
262
+ const zh = {
263
+ sectionLabel: "TLRP 角色与双态引擎",
264
+ title: "TLRP 角色与双态引擎",
265
+ description: "通用角色扮演与双态路由引擎:管理运行模式、角色设定、关键词触发映射与预设导入导出。",
266
+ loading: "正在读取 tlrp 配置…",
267
+ unavailable: "本部署没有服出 tlrp 的设置命名空间,工作室暂时不可用。",
268
+ readOnly: "本部署的设置为只读,改动不会保存。",
269
+ saving: "保存中…",
270
+ saved: "已保存",
271
+ saveFailed: "本部署拒绝了这次写入,已回读宿主值。",
272
+ writeFailedHint: "写入失败:宿主可能处于只读模式,或命名空间未服出。",
273
+
274
+ modeSection: "形态矩阵工作室 Mode Studio",
275
+ modeSectionHint: "运行形态可以像角色一样自由增删改:每个形态有自己的行为准则、工具策略、状态面板模板与触发指令;auto 按优先级与关键词自动分流。",
276
+ styleSection: "风格层(轻量注入)",
277
+ styleSectionHint: "只把角色的语言风格、表情与本地贴纸追加进上下文,不演微动作;人设是否完整注入仍由形态的「注入完整人设」决定。对话里可用 /style on|off|default|status 临时开关。",
278
+ field_styleLayerDefault: "全局默认",
279
+ hint_styleLayerDefault: "形态设为「跟随全局默认」时用这里。只带语气=口吻像角色但仍可演;对话体=只说话,禁旁白、动作、环境与外貌描写。",
280
+ field_styleEmojiHint: "表情(emoji)用量",
281
+ hint_styleEmojiHint: "写进上下文的 emoji 使用规则;留空则声明本轮不用 emoji。",
282
+ field_stickerDir: "表情包目录",
283
+ hint_stickerDir: "留空则用 ~/.dsh/stickers/<角色 ID>;只读该目录顶层,文件名即标签,支持 png/jpg/jpeg/webp/gif。",
284
+ field_galleryDir: "图库目录",
285
+ hint_galleryDir: "留空则用 ~/.dsh/gallery;同样只读顶层,文件名即标签。",
286
+ field_maxStickersPerTurn: "每轮最多贴图",
287
+ hint_maxStickersPerTurn: "表情包与图库共用这个上限;0 表示整轮禁止贴图。",
288
+ styleSwatch: "当前判定",
289
+ sectionsToolbarExpand: "全部展开",
290
+ sectionsToolbarCollapse: "全部收起",
291
+ sectionsSummaryModes: "{count} 个形态",
292
+ sectionsSummaryTriggers: "{count} 条触发",
293
+ mode: "当前模式",
294
+ modeHint: "点顶部按钮直接锁定;选「自动路由」则按关键词与优先级每轮判定。",
295
+ modeAuto: "自动路由 (auto)",
296
+ modeRp: "少女陪伴态",
297
+ modeWork: "战术工作态",
298
+ modePicker: "编辑形态",
299
+ modePickerHint: "选择要编辑的形态;改动后点「保存形态修改」才写入配置。",
300
+ newMode: "新建形态",
301
+ duplicateMode: "复制当前形态",
302
+ removeMode: "删除当前形态",
303
+ saveMode: "保存形态修改",
304
+ setFallback: "设为 Auto 默认兜底形态",
305
+ cannotRemoveLastMode: "至少要保留一个形态。",
306
+ unknownMode: "未注册形态",
307
+ modeUnsavedHint: "形态表单已改动,点「保存形态修改」才会写入配置。",
308
+ fallbackBadge: "Auto 兜底",
309
+ legacySection: "0.2.0 兼容字段",
310
+ legacyHint: "这些旧字段仍会被读取,但只在形态自己没有配置时才生效;新配置建议直接写进对应形态。",
311
+ field_mode_id: "形态 ID",
312
+ hint_mode_id: "唯一标识,同时是 /mode 的参数与斜杠指令名(字母、数字、- 与 _)。",
313
+ field_mode_name: "形态显示名",
314
+ hint_mode_name: "顶部切换栏与运行时状态块里显示的名字。",
315
+ field_mode_commandAliases: "聊天触发指令别名",
316
+ hint_mode_commandAliases: "逗号或空格分隔,例如 /sam, 【萨姆】, 变身;以 / 开头的别名会注册成真实指令。",
317
+ field_mode_autoKeywords: "Auto 自动匹配正则",
318
+ hint_mode_autoKeywords: "每行一条正则或关键词;auto 模式下按命中数与优先级决定切到哪个形态。",
319
+ field_mode_autoPriority: "匹配优先级",
320
+ hint_mode_autoPriority: "数字越大越优先;所有形态都未命中时回落到「Auto 兜底」形态。",
321
+ field_mode_toolPolicy: "工具放行策略",
322
+ hint_mode_toolPolicy: "决定该形态下运维工具能不能调:放行全部/仅白名单/禁用全部。",
323
+ field_mode_allowedTools: "白名单(仅白名单策略生效)",
324
+ hint_mode_allowedTools: "逗号分隔的工具名或前缀,例如 tlmemory, ask_user_question, schedule。",
325
+ field_mode_injectCharacterPersona: "注入完整人设",
326
+ hint_mode_injectCharacterPersona: "关闭后只注入身份锁与称呼,适合纯运维形态。",
327
+ field_mode_enableStatusFooter: "强制底部状态面板",
328
+ hint_mode_enableStatusFooter: "开启后每轮回复末尾必须带状态同步块。",
329
+ field_mode_directivePrompt: "该形态专属行为准则",
330
+ hint_mode_directivePrompt: "多行文本;可用 {name}{id}{userTitle}{scene}{character}{footer}{tools} 占位符。留空则回落到内置的陪伴/运维行为族。",
331
+ field_mode_statusFooterOverride: "该形态专属状态栏模板覆盖",
332
+ hint_mode_statusFooterOverride: "留空则使用角色自己的状态面板模板;可用 {label}{scene}{dress}{os}{heat}{shield}{vocoder}{entropy}。",
333
+ field_mode_bindCharacterId: "联动角色",
334
+ hint_mode_bindCharacterId: "切到该形态时自动切换到的角色;留空表示保持当前角色。",
335
+ field_mode_styleLayer: "风格层",
336
+ hint_mode_styleLayer: "跟随全局/强制开/强制关;对话里的 /style 优先级最高。",
337
+ styleInherit: "跟随全局默认",
338
+ styleOn: "只带语气",
339
+ styleDialog: "对话体(只说话不演)",
340
+ styleOff: "强制关",
341
+ policyAllowAll: "放行全部工具",
342
+ policyWhitelist: "仅白名单",
343
+ policyBlockAll: "禁用全部工具",
344
+ modeNoDirective: "(未填专属准则,回落到内置行为族)",
345
+ modeAliasesLabel: "触发别名",
346
+ modePriorityLabel: "优先级",
347
+ modePolicyLabel: "工具策略",
348
+ modeDirectiveLabel: "专属准则",
349
+ modeBindKeep: "保持当前角色",
350
+ newModeName: "新形态",
351
+ modeIdInvalid: "形态 ID 只能是字母、数字、- 与 _,且不能为空。",
352
+ modeIdTaken: "这个形态 ID 已被占用,换一个。",
353
+ enabled: "总开关",
354
+ enabledHint: "关闭后不注入任何角色设定与双态指令,指令本身也失效。",
355
+ blockShell: "RP 态拦截运维工具(0.2.0)",
356
+ blockShellHint: "旧开关:只在形态自身没写工具策略时兜底,用于兼容 0.2.0 的 blockShellToolsInRp。",
357
+ footer: "强制底部状态面板(0.2.0)",
358
+ footerHint: "旧开关:形态自身没配 enableStatusFooter 时才生效。",
359
+ workKeywords: "Work 态自动触发词正则(0.2.0)",
360
+ workKeywordsHint: "旧字段:只在形态自身没写 autoKeywords 且族为 Work 时兜底;新配置请写进形态。",
361
+ rpAllowed: "RP 态放行的工具白名单(0.2.0)",
362
+ rpAllowedHint: "旧字段:只在形态自身没写 allowedTools 时兜底;新配置请写进形态。",
363
+ scene: "临时情景覆盖",
364
+ sceneHint: "留空则使用角色自己的默认情景。",
365
+ scenePlaceholder: "例如:现实宇宙·星舰医疗舱外",
366
+ apply: "应用",
367
+ clear: "清空",
368
+
369
+ studio: "角色工作室 Character Studio",
370
+ studioHint: "角色设定全部落在配置里,可自由编辑、复制、删除与新建;保存后立刻影响下一轮注入。",
371
+ character: "当前角色",
372
+ characterHint: "切换后本区表单与 {{tlrp_character}} 同步切换。",
373
+ newCharacter: "新建空白角色",
374
+ duplicateCharacter: "复制当前角色",
375
+ removeCharacter: "删除当前角色",
376
+ saveCharacter: "保存角色修改",
377
+ cannotRemoveLast: "至少要保留一个角色。",
378
+ unknownCharacter: "未知角色",
379
+ unsavedHint: "表单已改动,点「保存角色修改」才会写入配置。",
380
+ field_id: "角色 ID",
381
+ hint_id: "配置里的唯一标识;改名会同步搬走绑定在该角色上的触发器。",
382
+ field_name: "角色名",
383
+ hint_name: "显示在标题与角色下拉里的名字。",
384
+ field_userTitle: "专属称呼",
385
+ hint_userTitle: "角色怎么称呼对方,例如「开拓者」;禁用「用户」「您」。",
386
+ field_defaultScene: "默认情景",
387
+ hint_defaultScene: "没有临时情景覆盖时使用。",
388
+ field_identityLock: "绝对身份锁",
389
+ hint_identityLock: "不可违反的底线:不得自称 AI/助手,不得自曝运维身份。",
390
+ field_corePersona: "核心背景与性格",
391
+ hint_corePersona: "经历、反差与最在意的东西,决定角色为什么这样说话。",
392
+ field_appearanceAndSensory: "外貌与感官细节",
393
+ hint_appearanceAndSensory: "发色、瞳色、气味、体温这类可直接写进描写的锚点。",
394
+ field_actionPresets: "招牌微动作与情节预设",
395
+ hint_actionPresets: "害羞、开心、危机时各自的固定身体反应。",
396
+ field_speechStyle: "语言风格与禁用项",
397
+ hint_speechStyle: "语速、常用语气词、绝对不要出现的句式。",
398
+ field_dialogueExamples: "台词与互动范例",
399
+ hint_dialogueExamples: "给模型做语气校准的样张,一般 4~8 条。",
400
+ field_statusFooterTemplate: "底部状态面板模板",
401
+ hint_statusFooterTemplate: "RP 态每轮结尾必须按此结构逐项填写。",
402
+ newCharacterName: "新角色",
403
+ newSceneDefault: "未指定情景",
404
+ copySuffix: " 副本",
405
+
406
+ triggers: "触发词映射表",
407
+ triggersHint: "命中关键词时把注入文本并入本轮上下文;绑定角色的条目只对该角色生效。",
408
+ addTrigger: "新增触发词映射",
409
+ filterTriggers: "筛选",
410
+ filterAll: "全部条目",
411
+ filterGlobal: "仅全局",
412
+ triggerEnabled: "启用",
413
+ triggerNote: "名称备注",
414
+ triggerCharacter: "绑定角色",
415
+ triggerCharacterGlobal: "全局(所有角色)",
416
+ triggerKeywords: "关键词(逗号或正则)",
417
+ triggerKeywordsHint: "用逗号分隔多个关键词;写成 /正则/ 形式则按正则匹配。",
418
+ triggerInject: "注入文本",
419
+ triggerPriority: "优先级",
420
+ removeTrigger: "删除此条",
421
+ emptyTriggers: "还没有触发词映射,点上面的按钮新增一条。",
422
+ triggersCount: "共 {count} 条(启用 {enabled} 条)",
423
+
424
+ presets: "预设导入导出与调试探针",
425
+ presetsHint: "整库预设走 JSON 往返;预览区展示当前模式下 {{tlrp_character}} 与 {{tlrp_directive}} 的实际内容。",
426
+ exportJson: "导出当前角色 JSON",
427
+ exportHint: "导出包含全部角色与触发器的 JSON,可直接保存或转给别的部署。",
428
+ download: "下载 .json",
429
+ importJson: "从 JSON 导入角色/触发器",
430
+ importHint: "粘贴上面格式的 JSON:顶层可含 characters 与 triggers;同 id 覆盖,其余追加。",
431
+ importApply: "导入",
432
+ importFailed: "JSON 解析失败或结构不对。",
433
+ importDone: "已导入:角色 {characters} 个、触发器 {triggers} 条。",
434
+ preview: "预览当前合成 Prompt",
435
+ previewHint: "左侧是 {{tlrp_character}} 的拼装结果(与宿主渲染顺序一致),右侧是 {{tlrp_directive}} 当前模式的红线条目。",
436
+ previewCharacter: "{{tlrp_character}}",
437
+ previewDirective: "{{tlrp_directive}}",
438
+ previewLocalNote: "预览在浏览器侧按同一字段顺序拼装;宿主每轮还会追加实时状态块。",
439
+ rpRules: "RP 态红线:第一人称沉浸、必带 *斜体微动作与感官描写*、禁项目符号清单、结尾必带状态面板;身份锁优先于一切运维口径。",
440
+ workRules: "WORK 态红线:结论先行 → 证据 → 下一步;不写斜体动作、不写状态面板;危险操作先摊开再等确认。",
441
+ offRules: "总开关已关闭:本轮不注入角色设定与双态指令。",
442
+ currentMode: "当前生效",
443
+ userTitle: "称呼",
444
+ hitTriggers: "本轮命中设定",
445
+ };
446
+
447
+ /** 英文文案,键集与中文一致。 */
448
+ const en = {
449
+ sectionLabel: "TLRP character & mode engine",
450
+ title: "TLRP character & mode engine",
451
+ description: "A generic roleplay and dual-mode routing engine: run mode, character sheets, keyword triggers and preset import/export.",
452
+ loading: "Reading tlrp configuration…",
453
+ unavailable: "This deployment does not serve the tlrp settings namespace, so the studio is unavailable.",
454
+ readOnly: "Settings are read-only on this deployment.",
455
+ saving: "Saving…",
456
+ saved: "Saved",
457
+ saveFailed: "This deployment refused the write; values were re-read from the host.",
458
+ writeFailedHint: "Write failed: the host may be read-only, or the namespace is not served.",
459
+
460
+ modeSection: "Mode Studio",
461
+ modeSectionHint: "Runtime modes are as editable as characters: each one carries its own directive, tool policy, status-footer template and trigger aliases; auto routes by priority and keywords.",
462
+ styleSection: "Style layer (lightweight injection)",
463
+ styleSectionHint: "Appends only the character's speech style, emoji rules and local stickers to the context, with no action roleplay; whether the full persona is injected stays controlled by the mode's Inject full persona switch. Toggle it in chat with /style on|off|default|status.",
464
+ field_styleLayerDefault: "Global default",
465
+ hint_styleLayerDefault: "Used by modes set to Follow global default. Tone only keeps acting; Dialogue only forbids narration, actions, environment and appearance.",
466
+ field_styleEmojiHint: "Emoji allowance",
467
+ hint_styleEmojiHint: "Emoji rule injected into the context; empty means this turn uses no emoji.",
468
+ field_stickerDir: "Sticker directory",
469
+ hint_stickerDir: "Empty uses ~/.dsh/stickers/<character id>; only its top level is read and each file name is the tag (png/jpg/jpeg/webp/gif).",
470
+ field_galleryDir: "Gallery directory",
471
+ hint_galleryDir: "Empty uses ~/.dsh/gallery; likewise top level only, file name is the tag.",
472
+ field_maxStickersPerTurn: "Max images per turn",
473
+ hint_maxStickersPerTurn: "Shared by stickers and gallery; 0 forbids any image for the turn.",
474
+ styleSwatch: "Resolved",
475
+ sectionsToolbarExpand: "Expand all",
476
+ sectionsToolbarCollapse: "Collapse all",
477
+ sectionsSummaryModes: "{count} modes",
478
+ sectionsSummaryTriggers: "{count} triggers",
479
+ mode: "Mode",
480
+ modeHint: "Click a button to lock a mode; Auto routes every turn from keywords and priority.",
481
+ modeAuto: "Auto route (auto)",
482
+ modeRp: "Companion",
483
+ modeWork: "Tactical work",
484
+ modePicker: "Editing mode",
485
+ modePickerHint: "Pick a mode to edit; press Save mode to write it back.",
486
+ newMode: "New mode",
487
+ duplicateMode: "Duplicate current",
488
+ removeMode: "Delete current",
489
+ saveMode: "Save mode",
490
+ setFallback: "Make this the auto fallback",
491
+ cannotRemoveLastMode: "At least one mode must remain.",
492
+ unknownMode: "Unregistered mode",
493
+ modeUnsavedHint: "This mode form has unsaved edits; press Save mode to write them.",
494
+ fallbackBadge: "Auto fallback",
495
+ legacySection: "0.2.0 compatibility fields",
496
+ legacyHint: "These legacy fields are still read, but only when a mode has no value of its own; new configuration belongs in the mode.",
497
+ field_mode_id: "Mode id",
498
+ hint_mode_id: "Unique key, also the /mode argument and slash command (letters, digits, - and _).",
499
+ field_mode_name: "Display name",
500
+ hint_mode_name: "Shown in the switch bar and the runtime state block.",
501
+ field_mode_commandAliases: "Trigger aliases",
502
+ hint_mode_commandAliases: "Comma or space separated, e.g. /sam, 【萨姆】, 变身; aliases starting with / become real commands.",
503
+ field_mode_autoKeywords: "Auto match regexes",
504
+ hint_mode_autoKeywords: "One regex or keyword per line; auto picks the mode by hit count, then priority.",
505
+ field_mode_autoPriority: "Match priority",
506
+ hint_mode_autoPriority: "Higher wins; when nothing matches, the auto fallback mode takes over.",
507
+ field_mode_toolPolicy: "Tool policy",
508
+ hint_mode_toolPolicy: "Whether ops tools may run in this mode: all / allowlist only / none.",
509
+ field_mode_allowedTools: "Allowlist (allowlist policy only)",
510
+ hint_mode_allowedTools: "Comma-separated tool names or prefixes, e.g. tlmemory, ask_user_question, schedule.",
511
+ field_mode_injectCharacterPersona: "Inject full persona",
512
+ hint_mode_injectCharacterPersona: "When off, only the identity lock and address form are injected.",
513
+ field_mode_enableStatusFooter: "Force status footer",
514
+ hint_mode_enableStatusFooter: "When on, every reply must end with the status block.",
515
+ field_mode_directivePrompt: "Mode directive",
516
+ hint_mode_directivePrompt: "Multi-line; supports {name}{id}{userTitle}{scene}{character}{footer}{tools}. Empty falls back to the built-in family directive.",
517
+ field_mode_statusFooterOverride: "Status footer template override",
518
+ hint_mode_statusFooterOverride: "Empty uses the character template; supports {label}{scene}{dress}{os}{heat}{shield}{vocoder}{entropy}.",
519
+ field_mode_bindCharacterId: "Bound character",
520
+ hint_mode_bindCharacterId: "Character switched to when this mode activates; empty keeps the current one.",
521
+ field_mode_styleLayer: "Style layer",
522
+ hint_mode_styleLayer: "Follow global default / force on / force off; a /style command in chat wins over both.",
523
+ styleInherit: "Follow global default",
524
+ styleOn: "Tone only",
525
+ styleDialog: "Dialogue only",
526
+ styleOff: "Force off",
527
+ policyAllowAll: "Allow every tool",
528
+ policyWhitelist: "Allowlist only",
529
+ policyBlockAll: "Block every tool",
530
+ modeNoDirective: "(no mode directive — the built-in family directive applies)",
531
+ modeAliasesLabel: "Aliases",
532
+ modePriorityLabel: "Priority",
533
+ modePolicyLabel: "Tool policy",
534
+ modeDirectiveLabel: "Directive",
535
+ modeBindKeep: "Keep current character",
536
+ newModeName: "New mode",
537
+ modeIdInvalid: "A mode id may only use letters, digits, - and _ and cannot be empty.",
538
+ modeIdTaken: "That mode id is already taken.",
539
+ enabled: "Master switch",
540
+ enabledHint: "When off, no character sheet or directive is injected and the commands stop working.",
541
+ blockShell: "Block ops tools in RP",
542
+ blockShellHint: "In companion mode, shell/terminal/file tools are blocked; only the allowlist passes.",
543
+ footer: "Force status footer",
544
+ footerHint: "In companion mode every reply must end with the status block.",
545
+ workKeywords: "Work-mode trigger regexes",
546
+ workKeywordsHint: "One regex or keyword per line; in auto mode a single hit selects WORK.",
547
+ rpAllowed: "RP tool allowlist",
548
+ rpAllowedHint: "Comma-separated tool names or prefixes, default tlmemory; a hit always passes.",
549
+ scene: "Temporary scene override",
550
+ sceneHint: "Leave empty to use the character default scene.",
551
+ scenePlaceholder: "e.g. Real universe, outside the ship's medical bay",
552
+ apply: "Apply",
553
+ clear: "Clear",
554
+
555
+ studio: "Character Studio",
556
+ studioHint: "Every character field lives in configuration: edit, duplicate, delete or create freely; saving takes effect on the next turn.",
557
+ character: "Active character",
558
+ characterHint: "Switching re-binds this form and {{tlrp_character}} together.",
559
+ newCharacter: "New blank character",
560
+ duplicateCharacter: "Duplicate current",
561
+ removeCharacter: "Delete current",
562
+ saveCharacter: "Save character",
563
+ cannotRemoveLast: "At least one character must remain.",
564
+ unknownCharacter: "Unknown character",
565
+ unsavedHint: "This form has unsaved edits; press Save character to write them.",
566
+ field_id: "Character id",
567
+ hint_id: "Unique key in configuration; renaming also re-points triggers bound to it.",
568
+ field_name: "Name",
569
+ hint_name: "Shown in the header and the character picker.",
570
+ field_userTitle: "Address as",
571
+ hint_userTitle: "How the character addresses you; avoid generic placeholders.",
572
+ field_defaultScene: "Default scene",
573
+ hint_defaultScene: "Used unless a temporary scene override is set.",
574
+ field_identityLock: "Identity lock",
575
+ hint_identityLock: "Hard floor: never claim to be an AI or a service process.",
576
+ field_corePersona: "Background & personality",
577
+ hint_corePersona: "History, contrast and what the character cares about.",
578
+ field_appearanceAndSensory: "Appearance & senses",
579
+ hint_appearanceAndSensory: "Hair, eyes, scent, temperature — anchors for description.",
580
+ field_actionPresets: "Signature beats",
581
+ hint_actionPresets: "Fixed body language for shy, happy and crisis moments.",
582
+ field_speechStyle: "Voice & bans",
583
+ hint_speechStyle: "Pace, favourite particles, sentence shapes to avoid.",
584
+ field_dialogueExamples: "Dialogue samples",
585
+ hint_dialogueExamples: "Calibration samples, usually four to eight.",
586
+ field_statusFooterTemplate: "Status footer template",
587
+ hint_statusFooterTemplate: "In RP every reply must end with this structure.",
588
+ newCharacterName: "New character",
589
+ newSceneDefault: "Unspecified scene",
590
+ copySuffix: " copy",
591
+
592
+ triggers: "Trigger map",
593
+ triggersHint: "A keyword hit merges its text into this turn; a character-bound entry only applies to that character.",
594
+ addTrigger: "Add trigger",
595
+ filterTriggers: "Filter",
596
+ filterAll: "All entries",
597
+ filterGlobal: "Global only",
598
+ triggerEnabled: "Enabled",
599
+ triggerNote: "Note",
600
+ triggerCharacter: "Bound character",
601
+ triggerCharacterGlobal: "Global (all characters)",
602
+ triggerKeywords: "Keywords (comma or regex)",
603
+ triggerKeywordsHint: "Comma separates keywords; surround with slashes to match as regex.",
604
+ triggerInject: "Injected text",
605
+ triggerPriority: "Priority",
606
+ removeTrigger: "Delete this entry",
607
+ emptyTriggers: "No triggers yet — use the button above to add one.",
608
+ triggersCount: "{count} entries ({enabled} enabled)",
609
+
610
+ presets: "Presets & debug probe",
611
+ presetsHint: "Presets round-trip as JSON; the preview shows the live {{tlrp_character}} and {{tlrp_directive}} content for the current mode.",
612
+ exportJson: "Export JSON",
613
+ exportHint: "Exports every character and trigger; save it or move it to another deployment.",
614
+ download: "Download .json",
615
+ importJson: "Import characters / triggers",
616
+ importHint: "Paste JSON of the same shape: top level may carry characters and triggers; same id overwrites, the rest appends.",
617
+ importApply: "Import",
618
+ importFailed: "JSON parse failed or the shape is wrong.",
619
+ importDone: "Imported {characters} character(s) and {triggers} trigger(s).",
620
+ preview: "Preview composed prompt",
621
+ previewHint: "Left is the assembled {{tlrp_character}} (same field order as the host); right lists the active {{tlrp_directive}} rules.",
622
+ previewCharacter: "{{tlrp_character}}",
623
+ previewDirective: "{{tlrp_directive}}",
624
+ previewLocalNote: "This preview assembles fields in the browser; the host also appends the live turn-context block.",
625
+ rpRules: "RP rules: first person, always *italic action and sensory beats*, no bullet lists, status footer at the end; the identity lock outranks ops framing.",
626
+ workRules: "WORK rules: conclusion first, then evidence, then next step; no italic action, no status footer; risky operations are laid out and confirmed first.",
627
+ offRules: "Master switch is off: no character sheet and no directive are injected.",
628
+ currentMode: "Effective",
629
+ userTitle: "Address as",
630
+ hitTriggers: "Matched entries",
631
+ };
632
+
633
+ /* ────────────────────────── 样式 ────────────────────────── */
634
+
635
+ /** 只用宿主的设计令牌,缺令牌时退回中性值。 */
636
+ const CSS = `
637
+ .tlrp-root { display: flex; flex-direction: column; gap: 18px; padding: 4px 2px 8px; }
638
+ .tlrp-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-text-primary, #e8eaf0); margin: 0; }
639
+ .tlrp-desc { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-text-secondary, #9aa3b2); margin: 4px 0 0; }
640
+ .tlrp-section { border: 1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,.09)); border-radius: 10px; padding: 14px 16px 16px; background: var(--dsw-alias-bg-raised, rgba(255,255,255,.025)); display: flex; flex-direction: column; gap: 12px; }
641
+ .tlrp-section-head { display: flex; flex-direction: column; gap: 3px; }
642
+ .tlrp-section-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-text-primary, #e8eaf0); }
643
+ .tlrp-section-hint { font-size: 11.5px; line-height: 1.55; color: var(--dsw-alias-text-secondary, #9aa3b2); }
644
+ .tlrp-section-toggle { display: flex; align-items: flex-start; gap: 8px; width: 100%; margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; }
645
+ .tlrp-section-toggle:focus-visible { outline: 2px solid var(--dsw-alias-border-accent, #66e0c2); outline-offset: 3px; border-radius: 6px; }
646
+ .tlrp-section-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
647
+ .tlrp-section-chevron { flex: 0 0 12px; margin-top: 3px; color: var(--dsw-alias-text-secondary, #9aa3b2); transition: transform .16s ease; }
648
+ .tlrp-section[data-open="true"] .tlrp-section-chevron { transform: rotate(90deg); }
649
+ .tlrp-section-summary { display: flex; flex-wrap: wrap; gap: 6px; }
650
+ .tlrp-section[data-open="true"] .tlrp-section-summary { display: none; }
651
+ .tlrp-section-body { display: flex; flex-direction: column; gap: 12px; }
652
+ .tlrp-section[data-open="false"] .tlrp-section-body { display: none; }
653
+ .tlrp-sections-toolbar { display: flex; justify-content: flex-end; gap: 8px; }
654
+ .tlrp-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
655
+ .tlrp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }
656
+ .tlrp-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
657
+ .tlrp-field-label { font-size: 12px; color: var(--dsw-alias-text-primary, #e8eaf0); font-weight: 500; }
658
+ .tlrp-field-hint { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-text-secondary, #9aa3b2); }
659
+ .tlrp-input, .tlrp-textarea, .tlrp-select {
660
+ width: 100%; box-sizing: border-box; font: inherit; font-size: 12.5px; line-height: 1.55;
661
+ color: var(--dsw-alias-text-primary, #e8eaf0);
662
+ background: var(--dsw-alias-bg-sunken, rgba(0,0,0,.22));
663
+ border: 1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,.12));
664
+ border-radius: 7px; padding: 7px 9px; outline: none;
665
+ }
666
+ .tlrp-textarea { resize: vertical; min-height: 38px; font-family: inherit; }
667
+ .tlrp-input:focus, .tlrp-textarea:focus, .tlrp-select:focus { border-color: var(--dsw-alias-border-accent, #66e0c2); }
668
+ .tlrp-input:disabled, .tlrp-textarea:disabled, .tlrp-select:disabled { opacity: .55; }
669
+ .tlrp-btn {
670
+ font: inherit; font-size: 12px; line-height: 1; padding: 7px 11px; border-radius: 7px; cursor: pointer;
671
+ color: var(--dsw-alias-text-primary, #e8eaf0);
672
+ background: var(--dsw-alias-bg-raised, rgba(255,255,255,.06));
673
+ border: 1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,.14));
674
+ }
675
+ .tlrp-btn:hover:not(:disabled) { border-color: var(--dsw-alias-border-accent, #66e0c2); }
676
+ .tlrp-btn:disabled { opacity: .5; cursor: not-allowed; }
677
+ .tlrp-btn[data-active="true"] { background: var(--dsw-alias-bg-accent, rgba(102,224,194,.16)); border-color: var(--dsw-alias-border-accent, #66e0c2); }
678
+ .tlrp-btn[data-danger="true"]:hover:not(:disabled) { border-color: #e2686f; color: #e2686f; }
679
+ .tlrp-chip { font-size: 11.5px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,.14)); color: var(--dsw-alias-text-secondary, #9aa3b2); }
680
+ .tlrp-chip b { color: var(--dsw-alias-text-primary, #e8eaf0); font-weight: 600; }
681
+ .tlrp-switch { display: flex; gap: 8px; align-items: flex-start; }
682
+ .tlrp-switch input { margin-top: 2px; accent-color: var(--dsw-alias-border-accent, #66e0c2); }
683
+ .tlrp-card { border: 1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,.1)); border-radius: 9px; padding: 12px; display: flex; flex-direction: column; gap: 10px; background: var(--dsw-alias-bg-sunken, rgba(0,0,0,.16)); }
684
+ .tlrp-card-head { display: flex; gap: 10px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
685
+ .tlrp-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow: auto; }
686
+ .tlrp-details { border: 1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,.1)); border-radius: 9px; padding: 10px 12px; }
687
+ .tlrp-details > summary { cursor: pointer; font-size: 12.5px; font-weight: 500; }
688
+ .tlrp-preview { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-top: 10px; }
689
+ .tlrp-note { font-size: 11px; color: var(--dsw-alias-text-secondary, #9aa3b2); }
690
+ .tlrp-warn { font-size: 11.5px; color: #e2a86b; }
691
+ `;
692
+
693
+ /** 注入一次样式;早期文档没有 head 时静默跳过,绝不连累模块求值。 */
694
+ function injectCss() {
695
+ try {
696
+ if (typeof document === "undefined") return;
697
+ if (document.querySelector('style[data-plugin-css="dsh-plugin-tlrp"]')) return;
698
+ const style = document.createElement("style");
699
+ style.setAttribute("data-plugin-css", "dsh-plugin-tlrp");
700
+ style.textContent = CSS;
701
+ (document.head || document.documentElement).appendChild(style);
702
+ } catch {
703
+ /* 无 DOM 环境下不注入样式 */
704
+ }
705
+ }
706
+
707
+ /* ─────────────────────── 基础控件 ─────────────────────── */
708
+
709
+ /* ── 分区收起状态:默认只展开第一块,用户的选择记在 localStorage ── */
710
+
711
+ const SECTION_IDS = ["mode", "style", "studio", "triggers", "presets"];
712
+ const SECTION_STORAGE_KEY = "tlrp.sections.open";
713
+
714
+ function mapSections(open) {
715
+ const next = {};
716
+ for (const id of SECTION_IDS) next[id] = open === true;
717
+ return next;
718
+ }
719
+
720
+ function persistSectionState(state) {
721
+ try {
722
+ window.localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(state));
723
+ } catch (error) {
724
+ /* 隐私模式或存储配额:只丢记忆,不影响本次收展 */
725
+ }
726
+ return state;
727
+ }
728
+
729
+ function loadSectionState() {
730
+ try {
731
+ const raw = window.localStorage.getItem(SECTION_STORAGE_KEY);
732
+ const parsed = raw ? JSON.parse(raw) : null;
733
+ if (parsed && typeof parsed === "object") {
734
+ const next = {};
735
+ let anyOpen = false;
736
+ for (const id of SECTION_IDS) {
737
+ next[id] = parsed[id] === true;
738
+ if (next[id]) anyOpen = true;
739
+ }
740
+ if (anyOpen) return next;
741
+ }
742
+ } catch (error) {
743
+ /* 读不到就用默认 */
744
+ }
745
+ return { mode: true };
746
+ }
747
+
748
+ /** 分区容器:可收起卡片(标题行是按钮,收起后留一行状态摘要)。 */
749
+ function Section(props) {
750
+ const open = props.open === undefined ? true : props.open === true;
751
+ const header = h(
752
+ "button",
753
+ {
754
+ className: "tlrp-section-toggle",
755
+ type: "button",
756
+ "aria-expanded": open ? "true" : "false",
757
+ onClick: props.onToggle ? () => props.onToggle(props.id) : undefined,
758
+ },
759
+ h(
760
+ "svg",
761
+ {
762
+ className: "tlrp-section-chevron",
763
+ width: 12,
764
+ height: 12,
765
+ viewBox: "0 0 12 12",
766
+ fill: "none",
767
+ stroke: "currentColor",
768
+ strokeWidth: 1.6,
769
+ strokeLinecap: "round",
770
+ strokeLinejoin: "round",
771
+ "aria-hidden": "true",
772
+ focusable: "false",
773
+ },
774
+ h("path", { d: "M4.5 2.5 L8 6 L4.5 9.5" })
775
+ ),
776
+ h(
777
+ "div",
778
+ { className: "tlrp-section-main" },
779
+ h("div", { className: "tlrp-section-title" }, props.title),
780
+ props.hint ? h("div", { className: "tlrp-section-hint" }, props.hint) : null
781
+ )
782
+ );
783
+ return h(
784
+ "section",
785
+ { className: "tlrp-section", "data-open": open ? "true" : "false" },
786
+ header,
787
+ props.summary && props.summary.length > 0
788
+ ? h(
789
+ "div",
790
+ { className: "tlrp-section-summary" },
791
+ props.summary.map((text, index) => h("span", { key: String(index), className: "tlrp-chip" }, text))
792
+ )
793
+ : null,
794
+ h(
795
+ "div",
796
+ { className: "tlrp-section-body", hidden: open ? undefined : true },
797
+ ...(Array.isArray(props.children) ? props.children : [props.children])
798
+ )
799
+ ); }
800
+
801
+ /** 字段容器:标签 + 控件 + 提示。 */
802
+ function Field(props) {
803
+ return h(
804
+ "label",
805
+ { className: "tlrp-field" },
806
+ h("span", { className: "tlrp-field-label" }, props.label),
807
+ props.children,
808
+ props.hint ? h("span", { className: "tlrp-field-hint" }, props.hint) : null
809
+ );
810
+ }
811
+
812
+ /** 单行输入。 */
813
+ function TextInput(props) {
814
+ return h("input", {
815
+ className: "tlrp-input",
816
+ type: "text",
817
+ value: props.value ?? "",
818
+ placeholder: props.placeholder ?? "",
819
+ disabled: props.disabled === true,
820
+ onChange: (event) => props.onChange(event.target.value),
821
+ });
822
+ }
823
+
824
+ /** 多行输入。 */
825
+ function TextArea(props) {
826
+ return h("textarea", {
827
+ className: "tlrp-textarea",
828
+ rows: props.rows ?? 3,
829
+ value: props.value ?? "",
830
+ placeholder: props.placeholder ?? "",
831
+ disabled: props.disabled === true,
832
+ spellCheck: false,
833
+ onChange: (event) => props.onChange(event.target.value),
834
+ });
835
+ }
836
+
837
+ /** 下拉选择。 */
838
+ function Select(props) {
839
+ return h(
840
+ "select",
841
+ {
842
+ className: "tlrp-select",
843
+ value: props.value ?? "",
844
+ disabled: props.disabled === true,
845
+ onChange: (event) => props.onChange(event.target.value),
846
+ },
847
+ props.options.map((option) =>
848
+ h("option", { key: option.value, value: option.value }, option.label)
849
+ )
850
+ );
851
+ }
852
+
853
+ /** 开关(原生 checkbox + 说明)。 */
854
+ function Toggle(props) {
855
+ return h(
856
+ "label",
857
+ { className: "tlrp-switch" },
858
+ h("input", {
859
+ type: "checkbox",
860
+ checked: props.checked === true,
861
+ disabled: props.disabled === true,
862
+ onChange: (event) => props.onChange(event.target.checked),
863
+ }),
864
+ h(
865
+ "span",
866
+ null,
867
+ h("span", { className: "tlrp-field-label" }, props.label),
868
+ props.hint ? h("span", { className: "tlrp-field-hint", style: { display: "block" } }, props.hint) : null
869
+ )
870
+ );
871
+ }
872
+
873
+ /** 按钮。 */
874
+ function Button(props) {
875
+ return h(
876
+ "button",
877
+ {
878
+ type: "button",
879
+ className: "tlrp-btn",
880
+ disabled: props.disabled === true,
881
+ "data-active": props.active === true ? "true" : "false",
882
+ "data-danger": props.danger === true ? "true" : undefined,
883
+ onClick: props.onClick,
884
+ },
885
+ props.children
886
+ );
887
+ }
888
+
889
+ /* ──────────────────── 预览:与宿主同序拼装 ──────────────────── */
890
+
891
+ /**
892
+ * 按宿主 renderCharacterBlock 的字段顺序拼出 {{tlrp_character}} 文本。
893
+ * 只用于调试预览;真正的注入仍由宿主渲染。
894
+ * @param character - 角色对象。
895
+ * @param scene - 当前情景。
896
+ * @param footerLabel - 状态面板标题。
897
+ * @returns 拼装后的设定块文本。
898
+ */
899
+ function assembleCharacterBlock(character, scene, footerLabel) {
900
+ const char = character && typeof character === "object" ? character : EMPTY_CHARACTER;
901
+ const lines = [
902
+ `## 角色设定(当前激活:${char.name || char.id || ""})`,
903
+ `- 角色 ID:${char.id || ""}`,
904
+ `- 称呼对方的专属称谓:${char.userTitle || "对方"}`,
905
+ `- 当前所处情景:${scene || char.defaultScene || "未指定"}`,
906
+ "",
907
+ "### 绝对身份锁(不可违反)",
908
+ char.identityLock || "",
909
+ ];
910
+ if (char.corePersona) lines.push("", "### 角色核心背景与性格(经历与反差)", char.corePersona);
911
+ if (char.appearanceAndSensory) lines.push("", "### 外貌与感官细节(描写锚点)", char.appearanceAndSensory);
912
+ if (char.actionPresets) lines.push("", "### 招牌微动作与情节预设库", char.actionPresets);
913
+ if (char.speechStyle) lines.push("", "### 语言风格与禁用项", char.speechStyle);
914
+ if (char.dialogueExamples) lines.push("", "### 台词与互动范例(语气锚点,不要照抄)", char.dialogueExamples);
915
+ if (char.statusFooterTemplate) {
916
+ lines.push("", "### 底部状态同步面板模板", "必须按下面的结构逐项填写,字段名与顺序不可改:", char.statusFooterTemplate);
917
+ }
918
+ return lines.join("\n");
919
+ }
920
+
921
+ /** 关键词与注入文本之间的人读分隔。 */
922
+ const KEYWORD_SPLIT = /[,,、|]/;
923
+
924
+ /** 把触发器上的关键词数组渲染成编辑用文本。 */
925
+ function keywordsToText(keywords) {
926
+ return Array.isArray(keywords) ? keywords.join(", ") : "";
927
+ }
928
+
929
+ /** 把编辑用文本拆回关键词数组。 */
930
+ function textToKeywords(text) {
931
+ return String(text ?? "")
932
+ .split(KEYWORD_SPLIT)
933
+ .map((word) => word.trim())
934
+ .filter(Boolean);
935
+ }
936
+
937
+ /** 为每次新建动作生成一个稳定 id 片段。 */
938
+ function mintId() {
939
+ const stamp = Date.now().toString(36);
940
+ const salt = Math.random().toString(36).slice(2, 6);
941
+ return `${stamp}${salt}`;
942
+ }
943
+
944
+ /** 比较两次数组是否同构(用于决定是否把本地草稿让位给宿主快照)。 */
945
+ function sameJson(a, b) {
946
+ try {
947
+ return JSON.stringify(a) === JSON.stringify(b);
948
+ } catch {
949
+ return false;
950
+ }
951
+ }
952
+
953
+ /** 带尾部防抖的提交器:连续编辑只写一次配置。 */
954
+ function useCommitter(commit, delay) {
955
+ const timer = useRef(null);
956
+ const pending = useRef(null);
957
+ useEffect(
958
+ () => () => {
959
+ if (timer.current) clearTimeout(timer.current);
960
+ },
961
+ []
962
+ );
963
+ return useCallback(
964
+ (value) => {
965
+ pending.current = value;
966
+ if (timer.current) clearTimeout(timer.current);
967
+ timer.current = setTimeout(() => {
968
+ timer.current = null;
969
+ const next = pending.current;
970
+ pending.current = null;
971
+ commit(next);
972
+ }, delay);
973
+ },
974
+ [commit, delay]
975
+ );
976
+ }
977
+
978
+ /* ──────────────────────── 工作室主体 ──────────────────────── */
979
+
980
+ /**
981
+ * 四分区工作室。
982
+ * @param props - 渲染器注入:useTlrp 快照钩子 + set/unset 写入器 + t 字典。
983
+ */
984
+ function TlrpStudio(props) {
985
+ const t = props.t;
986
+ const snapshot = (typeof props.useTlrp === "function" ? props.useTlrp((state) => state) : null) || {};
987
+ const value = snapshot.value && typeof snapshot.value === "object" ? snapshot.value : {};
988
+ const writable = snapshot.writable === true;
989
+ const ready = snapshot.status === "ready";
990
+
991
+ /** 写入一项配置;控制器自己负责与宿主对账。 */
992
+ const write = useCallback(
993
+ (field, next) => {
994
+ try {
995
+ props.set(field, next);
996
+ } catch (error) {
997
+ ctxLog(error);
998
+ }
999
+ },
1000
+ [props]
1001
+ );
1002
+
1003
+ const characters = Array.isArray(value.characters) ? value.characters : [];
1004
+ const triggers = Array.isArray(value.triggers) ? value.triggers : [];
1005
+ const activeId = typeof value.activeCharacterId === "string" && value.activeCharacterId
1006
+ ? value.activeCharacterId
1007
+ : characters[0]?.id ?? "";
1008
+ const activeCharacter = useMemo(
1009
+ () => characters.find((entry) => entry?.id === activeId) ?? characters[0] ?? EMPTY_CHARACTER,
1010
+ [characters, activeId]
1011
+ );
1012
+ const modes = useMemo(() => normalizeModeList(value.modes), [value.modes]);
1013
+ const rawMode = typeof value.mode === "string" ? value.mode.trim() : "";
1014
+ const mode = rawMode && rawMode !== "auto" && modes.some((entry) => entry.id === rawMode) ? rawMode : "auto";
1015
+ const modeById = useMemo(() => new Map(modes.map((entry) => [entry.id, entry])), [modes]);
1016
+ const activeMode = modeById.get(mode) ?? null;
1017
+ const scene = typeof value.customSceneOverride === "string" ? value.customSceneOverride : "";
1018
+
1019
+ /* ── 角色表单草稿:切换角色时同步,编辑中不随快照抖动 ── */
1020
+ const [draft, setDraft] = useState(() => ({ ...EMPTY_CHARACTER, ...activeCharacter }));
1021
+ const [draftFor, setDraftFor] = useState(activeId);
1022
+ const [dirty, setDirty] = useState(false);
1023
+ useEffect(() => {
1024
+ if (draftFor === activeId) return;
1025
+ setDraft({ ...EMPTY_CHARACTER, ...activeCharacter });
1026
+ setDraftFor(activeId);
1027
+ setDirty(false);
1028
+ }, [activeId, activeCharacter, draftFor]);
1029
+
1030
+ const patchDraft = (key, next) => {
1031
+ setDraft((previous) => ({ ...previous, [key]: next }));
1032
+ setDirty(true);
1033
+ };
1034
+
1035
+ const saveCharacter = () => {
1036
+ const nextId = String(draft.id ?? "").trim() || activeId || `character-${mintId()}`;
1037
+ const merged = { ...activeCharacter, ...draft, id: nextId };
1038
+ const list = characters.length === 0
1039
+ ? [merged]
1040
+ : characters.map((entry) => (entry?.id === activeId ? merged : entry));
1041
+ write("characters", list);
1042
+ if (nextId !== activeId) {
1043
+ write("activeCharacterId", nextId);
1044
+ if (triggers.some((entry) => entry?.characterId === activeId)) {
1045
+ write("triggers", triggers.map((entry) => (entry?.characterId === activeId ? { ...entry, characterId: nextId } : entry)));
1046
+ }
1047
+ }
1048
+ setDraft(merged);
1049
+ setDraftFor(nextId);
1050
+ setDirty(false);
1051
+ };
1052
+
1053
+ const createCharacter = () => {
1054
+ const id = `character-${mintId()}`;
1055
+ const created = {
1056
+ ...EMPTY_CHARACTER,
1057
+ id,
1058
+ name: t("newCharacterName"),
1059
+ defaultScene: t("newSceneDefault"),
1060
+ };
1061
+ write("characters", [...characters, created]);
1062
+ write("activeCharacterId", id);
1063
+ setDraft(created);
1064
+ setDraftFor(id);
1065
+ setDirty(false);
1066
+ };
1067
+
1068
+ const duplicateCharacter = () => {
1069
+ const id = `${activeId || "character"}-copy-${mintId()}`;
1070
+ const copy = { ...EMPTY_CHARACTER, ...activeCharacter, id, name: `${activeCharacter?.name || activeId}${t("copySuffix")}` };
1071
+ write("characters", [...characters, copy]);
1072
+ write("activeCharacterId", id);
1073
+ setDraft(copy);
1074
+ setDraftFor(id);
1075
+ setDirty(false);
1076
+ };
1077
+
1078
+ const removeCharacter = () => {
1079
+ if (characters.length <= 1) {
1080
+ setNotice(t("cannotRemoveLast"));
1081
+ return;
1082
+ }
1083
+ const list = characters.filter((entry) => entry?.id !== activeId);
1084
+ const nextId = list[0]?.id ?? "";
1085
+ write("characters", list);
1086
+ write("activeCharacterId", nextId);
1087
+ setDraft({ ...EMPTY_CHARACTER, ...(list[0] ?? EMPTY_CHARACTER) });
1088
+ setDraftFor(nextId);
1089
+ setDirty(false);
1090
+ };
1091
+
1092
+ /* ── 触发器:本地即时回显 + 防抖落库 ── */
1093
+ const [localTriggers, setLocalTriggers] = useState(triggers);
1094
+ const triggersDirty = useRef(false);
1095
+ useEffect(() => {
1096
+ if (!triggersDirty.current) setLocalTriggers(triggers);
1097
+ }, [triggers]);
1098
+ const commitTriggers = useCommitter(
1099
+ useCallback(
1100
+ (next) => {
1101
+ triggersDirty.current = false;
1102
+ write("triggers", next);
1103
+ },
1104
+ [write]
1105
+ ),
1106
+ 600
1107
+ );
1108
+ const editTriggers = (next) => {
1109
+ triggersDirty.current = true;
1110
+ setLocalTriggers(next);
1111
+ commitTriggers(next);
1112
+ };
1113
+ const patchTrigger = (id, patch) => {
1114
+ editTriggers(localTriggers.map((entry) => (entry?.id === id ? { ...entry, ...patch } : entry)));
1115
+ };
1116
+ const addTrigger = () => {
1117
+ const created = { ...EMPTY_TRIGGER, id: `trigger-${mintId()}`, keywords: [] };
1118
+ editTriggers([...localTriggers, created]);
1119
+ setTriggerFilter("all");
1120
+ };
1121
+ const removeTrigger = (id) => {
1122
+ editTriggers(localTriggers.filter((entry) => entry?.id !== id));
1123
+ };
1124
+
1125
+ /* ── 触发词筛选:'all' | 'global' | 角色 id ── */
1126
+ const [triggerFilter, setTriggerFilter] = useState("all");
1127
+ const visibleTriggers = localTriggers.filter((entry) => {
1128
+ if (triggerFilter === "all") return true;
1129
+ if (triggerFilter === "global") return !entry?.characterId;
1130
+ return entry?.characterId === triggerFilter;
1131
+ });
1132
+ const characterOptions = characters.map((entry) => ({
1133
+ value: entry?.id ?? "",
1134
+ label: `${entry?.name || entry?.id || t("unknownCharacter")}(${entry?.id ?? ""})`,
1135
+ }));
1136
+
1137
+ /* ── 形态矩阵:下拉选项与表单草稿 ── */
1138
+ const modeOptions = modes.map((entry) => ({
1139
+ value: entry.id,
1140
+ label: `${entry.name || entry.id}(${entry.id})${entry.isDefaultFallback ? ` · ${t("fallbackBadge")}` : ""}`,
1141
+ }));
1142
+ const bindCharacterOptions = [{ value: "", label: t("modeBindKeep") }, ...characterOptions];
1143
+
1144
+ const [modeEditId, setModeEditId] = useState("");
1145
+ const editingMode = modeById.get(modeEditId) ?? modes[0] ?? null;
1146
+ const [modeDraft, setModeDraft] = useState(() => normalizeModeEntry(EMPTY_MODE, 0));
1147
+ const [modeDraftFor, setModeDraftFor] = useState("");
1148
+ const [modeDirty, setModeDirty] = useState(false);
1149
+ useEffect(() => {
1150
+ const target = editingMode;
1151
+ if (!target || modeDraftFor === target.id) return;
1152
+ setModeDraft(target);
1153
+ setModeDraftFor(target.id);
1154
+ setModeDirty(false);
1155
+ }, [editingMode, modeDraftFor]);
1156
+
1157
+ const patchMode = (key, next) => {
1158
+ setModeDraft((previous) => ({ ...previous, [key]: next }));
1159
+ setModeDirty(true);
1160
+ };
1161
+
1162
+ /** 把改动后的形态列表写回配置,并保证恰好有一个 Auto 兜底形态。 */
1163
+ const commitModes = (list, focusId) => {
1164
+ const normalized = normalizeModeList(list);
1165
+ if (normalized.length > 0 && !normalized.some((entry) => entry.isDefaultFallback)) {
1166
+ normalized[0] = { ...normalized[0], isDefaultFallback: true };
1167
+ }
1168
+ write("modes", normalized);
1169
+ const focus = normalized.find((entry) => entry.id === focusId) ?? normalized[0] ?? null;
1170
+ setModeDraft(focus ?? normalizeModeEntry(EMPTY_MODE, 0));
1171
+ setModeDraftFor(focus?.id ?? "");
1172
+ setModeDirty(false);
1173
+ return normalized;
1174
+ };
1175
+
1176
+ const saveMode = () => {
1177
+ const currentId = editingMode?.id ?? "";
1178
+ const nextId = String(modeDraft.id ?? "").trim().replace(/\s+/g, "-") || `mode-${mintId()}`;
1179
+ if (!MODE_ID_RE.test(nextId)) {
1180
+ setNotice(t("modeIdInvalid"));
1181
+ return;
1182
+ }
1183
+ if (modes.some((entry) => entry.id === nextId && entry.id !== currentId)) {
1184
+ setNotice(t("modeIdTaken"));
1185
+ return;
1186
+ }
1187
+ const merged = normalizeModeEntry({ ...(editingMode ?? {}), ...modeDraft, id: nextId }, 0);
1188
+ const list = modes.length === 0
1189
+ ? [merged]
1190
+ : modes.map((entry) => (entry.id === currentId ? merged : entry));
1191
+ const normalized = commitModes(list, nextId);
1192
+ // 形态改名后,若原本锁在它上面,跟着把锁定值搬过去
1193
+ if (mode === currentId && nextId !== currentId) write("mode", nextId);
1194
+ if (normalized.length === 0) setModeEditId("");
1195
+ else setModeEditId(nextId);
1196
+ };
1197
+
1198
+ const createMode = () => {
1199
+ const id = `mode-${mintId()}`;
1200
+ const created = normalizeModeEntry(
1201
+ { ...EMPTY_MODE, id, name: t("newModeName"), commandAliases: [] },
1202
+ 0
1203
+ );
1204
+ commitModes([...modes, created], id);
1205
+ setModeEditId(id);
1206
+ };
1207
+
1208
+ const duplicateMode = () => {
1209
+ const source = editingMode ?? normalizeModeEntry(EMPTY_MODE, 0);
1210
+ const id = `${source.id || "mode"}-copy-${mintId()}`;
1211
+ const copy = normalizeModeEntry(
1212
+ { ...source, id, name: `${source.name || source.id}${t("copySuffix")}`, isDefaultFallback: false },
1213
+ 0
1214
+ );
1215
+ commitModes([...modes, copy], id);
1216
+ setModeEditId(id);
1217
+ };
1218
+
1219
+ const removeMode = () => {
1220
+ if (!editingMode) return;
1221
+ if (modes.length <= 1) {
1222
+ setNotice(t("cannotRemoveLastMode"));
1223
+ return;
1224
+ }
1225
+ const list = modes.filter((entry) => entry.id !== editingMode.id);
1226
+ const normalized = commitModes(list, list[0]?.id ?? "");
1227
+ if (mode === editingMode.id) write("mode", "auto");
1228
+ setModeEditId(normalized[0]?.id ?? "");
1229
+ };
1230
+
1231
+ const setAsFallbackMode = () => {
1232
+ if (!editingMode) return;
1233
+ commitModes(
1234
+ modes.map((entry) => ({ ...entry, isDefaultFallback: entry.id === editingMode.id })),
1235
+ editingMode.id
1236
+ );
1237
+ setModeEditId(editingMode.id);
1238
+ };
1239
+
1240
+ /* ── 运行模式与路由规则的本地草稿 ── */
1241
+ const keywordsText = useMemo(() => {
1242
+ const raw = value.workModeKeywords;
1243
+ if (Array.isArray(raw)) return raw.join("\n");
1244
+ return typeof raw === "string" ? raw.split(/[\n,,、|]/).map((word) => word.trim()).filter(Boolean).join("\n") : "";
1245
+ }, [value.workModeKeywords]);
1246
+ const [keywordsDraft, setKeywordsDraft] = useState(keywordsText);
1247
+ useEffect(() => setKeywordsDraft(keywordsText), [keywordsText]);
1248
+ const [sceneDraft, setSceneDraft] = useState(scene);
1249
+ useEffect(() => setSceneDraft(scene), [scene]);
1250
+
1251
+ /* ── 导入导出 ── */
1252
+ const exportJson = useMemo(
1253
+ () => JSON.stringify({ characters, triggers: localTriggers }, null, 2),
1254
+ [characters, localTriggers]
1255
+ );
1256
+ const [importText, setImportText] = useState("");
1257
+ const [notice, setNotice] = useState("");
1258
+ const [openSections, setOpenSections] = useState(loadSectionState);
1259
+ const toggleSection = useCallback(
1260
+ (id) => {
1261
+ if (typeof id !== "string") return;
1262
+ setOpenSections((prev) => persistSectionState({ ...prev, [id]: prev?.[id] !== true }));
1263
+ },
1264
+ []
1265
+ );
1266
+ const setAllSections = useCallback((open) => setOpenSections(persistSectionState(mapSections(open))), []);
1267
+ const sectionProps = (id) => ({ id, open: openSections?.[id] === true, onToggle: toggleSection });
1268
+
1269
+ const applyImport = () => {
1270
+ let parsed;
1271
+ try {
1272
+ parsed = JSON.parse(importText);
1273
+ } catch {
1274
+ setNotice(t("importFailed"));
1275
+ return;
1276
+ }
1277
+ const incomingCharacters = Array.isArray(parsed?.characters)
1278
+ ? parsed.characters
1279
+ : parsed && typeof parsed === "object" && typeof parsed.id === "string"
1280
+ ? [parsed]
1281
+ : [];
1282
+ const incomingTriggers = Array.isArray(parsed?.triggers) ? parsed.triggers : [];
1283
+ if (incomingCharacters.length === 0 && incomingTriggers.length === 0) {
1284
+ setNotice(t("importFailed"));
1285
+ return;
1286
+ }
1287
+ if (incomingCharacters.length > 0) {
1288
+ const merged = [...characters];
1289
+ for (const entry of incomingCharacters) {
1290
+ const normalized = { ...EMPTY_CHARACTER, ...entry, id: String(entry?.id ?? "").trim() || `character-${mintId()}` };
1291
+ const at = merged.findIndex((existing) => existing?.id === normalized.id);
1292
+ if (at >= 0) merged[at] = normalized;
1293
+ else merged.push(normalized);
1294
+ }
1295
+ write("characters", merged);
1296
+ }
1297
+ if (incomingTriggers.length > 0) {
1298
+ const merged = [...localTriggers];
1299
+ for (const entry of incomingTriggers) {
1300
+ const normalized = {
1301
+ ...EMPTY_TRIGGER,
1302
+ ...entry,
1303
+ id: String(entry?.id ?? "").trim() || `trigger-${mintId()}`,
1304
+ keywords: Array.isArray(entry?.keywords) ? entry.keywords.map((word) => String(word)) : textToKeywords(entry?.keywords),
1305
+ };
1306
+ const at = merged.findIndex((existing) => existing?.id === normalized.id);
1307
+ if (at >= 0) merged[at] = normalized;
1308
+ else merged.push(normalized);
1309
+ }
1310
+ editTriggers(merged);
1311
+ }
1312
+ setNotice(t("importDone", { characters: incomingCharacters.length, triggers: incomingTriggers.length }));
1313
+ };
1314
+
1315
+ const downloadJson = () => {
1316
+ try {
1317
+ const blob = new Blob([exportJson], { type: "application/json" });
1318
+ const url = URL.createObjectURL(blob);
1319
+ const anchor = document.createElement("a");
1320
+ anchor.href = url;
1321
+ anchor.download = "tlrp-presets.json";
1322
+ anchor.click();
1323
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
1324
+ } catch (error) {
1325
+ ctxLog(error);
1326
+ }
1327
+ };
1328
+
1329
+ /* ── 预览 ── */
1330
+ const previewCharacter = assembleCharacterBlock(activeCharacter, scene || activeCharacter?.defaultScene || "", t("userTitle"));
1331
+ const previewMode = activeMode ?? modes.find((entry) => entry.isDefaultFallback === true) ?? modes[0] ?? null;
1332
+ const directiveLines = value.enabled === false
1333
+ ? [t("offRules")]
1334
+ : previewMode === null
1335
+ ? [t("unknownMode")]
1336
+ : [
1337
+ `${t("mode")}:${previewMode.name}(${previewMode.id})`,
1338
+ `${t("modePolicyLabel")}:${toolPolicyLabel(t, previewMode.toolPolicy)}`,
1339
+ `${t("modeAliasesLabel")}:${aliasesToText(previewMode.commandAliases) || "—"}`,
1340
+ `${t("modePriorityLabel")}:${previewMode.autoPriority}`,
1341
+ previewMode.directivePrompt ? `${t("modeDirectiveLabel")}:${previewMode.directivePrompt}` : t("modeNoDirective"),
1342
+ ];
1343
+
1344
+ if (!ready) {
1345
+ return h("div", { className: "tlrp-root" }, h("p", { className: "tlrp-desc" }, snapshot.status === "loading" ? t("loading") : t("unavailable")));
1346
+ }
1347
+
1348
+ const disabled = writable !== true;
1349
+ const modeStyleLayer =
1350
+ activeMode?.styleLayer === "on" || activeMode?.styleLayer === "dialog" || activeMode?.styleLayer === "off"
1351
+ ? activeMode.styleLayer
1352
+ : null;
1353
+ const effectiveStyleLayer =
1354
+ modeStyleLayer ??
1355
+ (STYLE_LAYER_OPTIONS.includes(value.styleLayerDefault) && value.styleLayerDefault !== "inherit"
1356
+ ? value.styleLayerDefault
1357
+ : "off");
1358
+
1359
+ return h(
1360
+ "div",
1361
+ { className: "tlrp-root" },
1362
+ h(
1363
+ "div",
1364
+ { className: "tlrp-row" },
1365
+ h("span", { className: "tlrp-chip" }, t("mode"), " ", h("b", null, mode === "auto" ? t("modeAuto") : activeMode?.name ?? mode)),
1366
+ h("span", { className: "tlrp-chip" }, t("character"), " ", h("b", null, activeCharacter?.name || activeCharacter?.id || t("unknownCharacter"))),
1367
+ h("span", { className: "tlrp-chip" }, t("userTitle"), " ", h("b", null, activeCharacter?.userTitle || "—")),
1368
+ h("span", { className: "tlrp-chip" }, t("triggersCount", { count: localTriggers.length, enabled: localTriggers.filter((entry) => entry?.enabled !== false).length }))
1369
+ ),
1370
+ disabled ? h("p", { className: "tlrp-warn" }, t("readOnly")) : null,
1371
+ notice ? h("p", { className: "tlrp-note" }, notice) : null,
1372
+ h(
1373
+ "div",
1374
+ { className: "tlrp-sections-toolbar" },
1375
+ h(Button, { onClick: () => setAllSections(true) }, t("sectionsToolbarExpand")),
1376
+ h(Button, { onClick: () => setAllSections(false) }, t("sectionsToolbarCollapse"))
1377
+ ),
1378
+
1379
+ /* ── A:形态矩阵工作室 Mode Studio ── */
1380
+ h(
1381
+ Section,
1382
+ {
1383
+ ...sectionProps("mode"),
1384
+ title: t("modeSection"),
1385
+ hint: t("modeSectionHint"),
1386
+ summary: [
1387
+ `${t("mode")}:${mode === "auto" ? t("modeAuto") : activeMode?.name ?? mode}`,
1388
+ t("sectionsSummaryModes", { count: modes.length }),
1389
+ ],
1390
+ },
1391
+ /* 顶部全局切换栏:自动路由 + 配置里的每一个形态 */
1392
+ h(
1393
+ "div",
1394
+ { className: "tlrp-row" },
1395
+ h(Button, { active: mode === "auto", disabled, onClick: () => write("mode", "auto") }, t("modeAuto")),
1396
+ modes.length === 0
1397
+ ? h("span", { className: "tlrp-note" }, t("unknownMode"))
1398
+ : modes.map((entry) =>
1399
+ h(
1400
+ Button,
1401
+ {
1402
+ key: entry.id,
1403
+ active: mode === entry.id,
1404
+ disabled,
1405
+ onClick: () => write("mode", entry.id),
1406
+ },
1407
+ entry.isDefaultFallback ? `${entry.name} · ${t("fallbackBadge")}` : entry.name
1408
+ )
1409
+ )
1410
+ ),
1411
+ h(
1412
+ "div",
1413
+ { className: "tlrp-row" },
1414
+ h("span", { className: "tlrp-chip" }, t("mode"), " ", h("b", null, mode === "auto" ? t("modeAuto") : activeMode?.name ?? mode)),
1415
+ h("span", { className: "tlrp-chip" }, t("modePolicyLabel"), " ", h("b", null, toolPolicyLabel(t, activeMode?.toolPolicy))),
1416
+ h("span", { className: "tlrp-chip" }, t("modePriorityLabel"), " ", h("b", null, String(activeMode?.autoPriority ?? "—"))),
1417
+ h("span", { className: "tlrp-chip" }, t("modeAliasesLabel"), " ", h("b", null, aliasesToText(activeMode?.commandAliases) || "—"))
1418
+ ),
1419
+ h(
1420
+ "div",
1421
+ { className: "tlrp-grid" },
1422
+ h(Toggle, {
1423
+ label: t("enabled"),
1424
+ hint: t("enabledHint"),
1425
+ checked: value.enabled !== false,
1426
+ disabled,
1427
+ onChange: (next) => write("enabled", next),
1428
+ })
1429
+ ),
1430
+ /* 形态 CRUD:与角色工作室同构 */
1431
+ h(
1432
+ "div",
1433
+ { className: "tlrp-row" },
1434
+ h(
1435
+ "span",
1436
+ { style: { minWidth: 220, flex: "0 1 300px" } },
1437
+ h(Select, {
1438
+ value: editingMode?.id ?? "",
1439
+ disabled,
1440
+ options: modeOptions.length > 0 ? modeOptions : [{ value: "", label: t("unknownMode") }],
1441
+ onChange: (next) => setModeEditId(next),
1442
+ })
1443
+ ),
1444
+ h(Button, { disabled, onClick: createMode }, t("newMode")),
1445
+ h(Button, { disabled, onClick: duplicateMode }, t("duplicateMode")),
1446
+ h(Button, { disabled, danger: true, onClick: removeMode }, t("removeMode")),
1447
+ h(
1448
+ Button,
1449
+ {
1450
+ disabled: disabled || editingMode?.isDefaultFallback === true,
1451
+ title: t("setFallback"),
1452
+ onClick: setAsFallbackMode,
1453
+ },
1454
+ t("setFallback")
1455
+ ),
1456
+ h(Button, { disabled: disabled || !modeDirty, onClick: saveMode }, t("saveMode"))
1457
+ ),
1458
+ modeDirty ? h("p", { className: "tlrp-note" }, t("modeUnsavedHint")) : null,
1459
+ h(
1460
+ "div",
1461
+ { className: "tlrp-grid" },
1462
+ h(
1463
+ Field,
1464
+ { label: t("field_mode_id"), hint: t("hint_mode_id") },
1465
+ h(TextInput, { value: modeDraft.id, disabled, onChange: (next) => patchMode("id", next) })
1466
+ ),
1467
+ h(
1468
+ Field,
1469
+ { label: t("field_mode_name"), hint: t("hint_mode_name") },
1470
+ h(TextInput, { value: modeDraft.name, disabled, onChange: (next) => patchMode("name", next) })
1471
+ ),
1472
+ h(
1473
+ Field,
1474
+ { label: t("field_mode_commandAliases"), hint: t("hint_mode_commandAliases") },
1475
+ h(TextInput, {
1476
+ value: aliasesToText(modeDraft.commandAliases),
1477
+ disabled,
1478
+ onChange: (next) => patchMode("commandAliases", textToAliases(next)),
1479
+ })
1480
+ ),
1481
+ h(
1482
+ Field,
1483
+ { label: t("field_mode_autoPriority"), hint: t("hint_mode_autoPriority") },
1484
+ h(TextInput, {
1485
+ value: String(modeDraft.autoPriority ?? ""),
1486
+ disabled,
1487
+ onChange: (next) => {
1488
+ const parsed = Number(next);
1489
+ patchMode("autoPriority", Number.isFinite(parsed) ? parsed : 0);
1490
+ },
1491
+ })
1492
+ ),
1493
+ h(
1494
+ Field,
1495
+ { label: t("field_mode_toolPolicy"), hint: t("hint_mode_toolPolicy") },
1496
+ h(Select, {
1497
+ value: modeDraft.toolPolicy,
1498
+ disabled,
1499
+ options: TOOL_POLICY_OPTIONS.map((policy) => ({ value: policy, label: toolPolicyLabel(t, policy) })),
1500
+ onChange: (next) => patchMode("toolPolicy", next),
1501
+ })
1502
+ ),
1503
+ h(
1504
+ Field,
1505
+ { label: t("field_mode_allowedTools"), hint: t("hint_mode_allowedTools") },
1506
+ h(TextInput, { value: modeDraft.allowedTools, disabled, onChange: (next) => patchMode("allowedTools", next) })
1507
+ ),
1508
+ h(
1509
+ Field,
1510
+ { label: t("field_mode_bindCharacterId"), hint: t("hint_mode_bindCharacterId") },
1511
+ h(Select, {
1512
+ value: modeDraft.bindCharacterId,
1513
+ disabled,
1514
+ options: bindCharacterOptions,
1515
+ onChange: (next) => patchMode("bindCharacterId", next),
1516
+ })
1517
+ )
1518
+ ),
1519
+ h(
1520
+ "div",
1521
+ { className: "tlrp-grid" },
1522
+ h(Toggle, {
1523
+ label: t("field_mode_injectCharacterPersona"),
1524
+ hint: t("hint_mode_injectCharacterPersona"),
1525
+ checked: modeDraft.injectCharacterPersona !== false,
1526
+ disabled,
1527
+ onChange: (next) => patchMode("injectCharacterPersona", next),
1528
+ }),
1529
+ h(Toggle, {
1530
+ label: t("field_mode_enableStatusFooter"),
1531
+ hint: t("hint_mode_enableStatusFooter"),
1532
+ checked: modeDraft.enableStatusFooter !== false,
1533
+ disabled,
1534
+ onChange: (next) => patchMode("enableStatusFooter", next),
1535
+ })
1536
+ ),
1537
+ h(
1538
+ Field,
1539
+ { label: t("field_mode_styleLayer"), hint: t("hint_mode_styleLayer") },
1540
+ h(Select, {
1541
+ value: modeDraft.styleLayer ?? "inherit",
1542
+ disabled,
1543
+ options: STYLE_LAYER_OPTIONS.map((flag) => ({ value: flag, label: styleLayerLabel(t, flag) })),
1544
+ onChange: (next) => patchMode("styleLayer", next),
1545
+ })
1546
+ ),
1547
+ h(
1548
+ Field,
1549
+ { label: t("field_mode_autoKeywords"), hint: t("hint_mode_autoKeywords") },
1550
+ h(TextArea, { rows: 3, value: modeDraft.autoKeywords, disabled, onChange: (next) => patchMode("autoKeywords", next) })
1551
+ ),
1552
+ h(
1553
+ Field,
1554
+ { label: t("field_mode_directivePrompt"), hint: t("hint_mode_directivePrompt") },
1555
+ h(TextArea, { rows: 10, value: modeDraft.directivePrompt, disabled, onChange: (next) => patchMode("directivePrompt", next) })
1556
+ ),
1557
+ h(
1558
+ Field,
1559
+ { label: t("field_mode_statusFooterOverride"), hint: t("hint_mode_statusFooterOverride") },
1560
+ h(TextArea, { rows: 5, value: modeDraft.statusFooterOverride, disabled, onChange: (next) => patchMode("statusFooterOverride", next) })
1561
+ ),
1562
+ h(
1563
+ "div",
1564
+ { className: "tlrp-grid" },
1565
+ h(
1566
+ Field,
1567
+ { label: t("workKeywords"), hint: t("workKeywordsHint") },
1568
+ h(TextArea, {
1569
+ rows: 4,
1570
+ value: keywordsDraft,
1571
+ disabled,
1572
+ onChange: (next) => {
1573
+ setKeywordsDraft(next);
1574
+ write("workModeKeywords", next.split("\n").map((line) => line.trim()).filter(Boolean));
1575
+ },
1576
+ })
1577
+ ),
1578
+ h(
1579
+ Field,
1580
+ { label: t("rpAllowed"), hint: t("rpAllowedHint") },
1581
+ h(TextInput, {
1582
+ value: typeof value.rpAllowedTools === "string" ? value.rpAllowedTools : "",
1583
+ disabled,
1584
+ onChange: (next) => write("rpAllowedTools", next),
1585
+ })
1586
+ )
1587
+ ),
1588
+ h(
1589
+ Field,
1590
+ { label: t("scene"), hint: t("sceneHint") },
1591
+ h(
1592
+ "div",
1593
+ { className: "tlrp-row" },
1594
+ h(TextInput, {
1595
+ value: sceneDraft,
1596
+ placeholder: t("scenePlaceholder"),
1597
+ disabled,
1598
+ onChange: setSceneDraft,
1599
+ }),
1600
+ h(Button, { disabled, onClick: () => write("customSceneOverride", sceneDraft) }, t("apply")),
1601
+ h(Button, { disabled, onClick: () => { setSceneDraft(""); write("customSceneOverride", ""); } }, t("clear"))
1602
+ )
1603
+ )
1604
+ ),
1605
+
1606
+ /* ── A2:风格层(语言风格轻量注入) ── */
1607
+ h(
1608
+ Section,
1609
+ {
1610
+ ...sectionProps("style"),
1611
+ title: t("styleSection"),
1612
+ hint: t("styleSectionHint"),
1613
+ summary: [`${t("styleSwatch")}:${styleLayerLabel(t, effectiveStyleLayer)}`],
1614
+ },
1615
+ h(
1616
+ "div",
1617
+ { className: "tlrp-row" },
1618
+ h(
1619
+ "span",
1620
+ { className: "tlrp-chip" },
1621
+ t("styleSwatch"),
1622
+ " ",
1623
+ h(
1624
+ "b",
1625
+ null,
1626
+ activeMode?.styleLayer === "on" ||
1627
+ activeMode?.styleLayer === "dialog" ||
1628
+ activeMode?.styleLayer === "off"
1629
+ ? styleLayerLabel(t, activeMode.styleLayer)
1630
+ : styleLayerLabel(
1631
+ t,
1632
+ STYLE_LAYER_OPTIONS.includes(value.styleLayerDefault) && value.styleLayerDefault !== "inherit"
1633
+ ? value.styleLayerDefault
1634
+ : "off"
1635
+ )
1636
+ )
1637
+ ),
1638
+ h("span", { className: "tlrp-chip" }, t("mode"), " ", h("b", null, activeMode?.name ?? "—"))
1639
+ ),
1640
+ h(
1641
+ "div",
1642
+ { className: "tlrp-grid" },
1643
+ h(
1644
+ Field,
1645
+ { label: t("field_styleLayerDefault"), hint: t("hint_styleLayerDefault") },
1646
+ h(Select, {
1647
+ value: ["on", "dialog", "off"].includes(value.styleLayerDefault) ? value.styleLayerDefault : "off",
1648
+ disabled,
1649
+ options: [
1650
+ { value: "on", label: t("styleOn") },
1651
+ { value: "dialog", label: t("styleDialog") },
1652
+ { value: "off", label: t("styleOff") },
1653
+ ],
1654
+ onChange: (next) => write("styleLayerDefault", next),
1655
+ })
1656
+ ),
1657
+ h(
1658
+ Field,
1659
+ { label: t("field_maxStickersPerTurn"), hint: t("hint_maxStickersPerTurn") },
1660
+ h(TextInput, {
1661
+ value: String(value.maxStickersPerTurn ?? 2),
1662
+ disabled,
1663
+ onChange: (next) => {
1664
+ const parsed = Number(next);
1665
+ write("maxStickersPerTurn", Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0);
1666
+ },
1667
+ })
1668
+ ),
1669
+ h(
1670
+ Field,
1671
+ { label: t("field_stickerDir"), hint: t("hint_stickerDir") },
1672
+ h(TextInput, {
1673
+ value: typeof value.stickerDir === "string" ? value.stickerDir : "",
1674
+ placeholder: "C:/Users/<you>/.dsh/stickers/<角色 ID>",
1675
+ disabled,
1676
+ onChange: (next) => write("stickerDir", next),
1677
+ })
1678
+ ),
1679
+ h(
1680
+ Field,
1681
+ { label: t("field_galleryDir"), hint: t("hint_galleryDir") },
1682
+ h(TextInput, {
1683
+ value: typeof value.galleryDir === "string" ? value.galleryDir : "",
1684
+ placeholder: "C:/Users/<you>/.dsh/gallery",
1685
+ disabled,
1686
+ onChange: (next) => write("galleryDir", next),
1687
+ })
1688
+ )
1689
+ ),
1690
+ h(
1691
+ Field,
1692
+ { label: t("field_styleEmojiHint"), hint: t("hint_styleEmojiHint") },
1693
+ h(TextArea, {
1694
+ rows: 3,
1695
+ value: typeof value.styleEmojiHint === "string" ? value.styleEmojiHint : "",
1696
+ disabled,
1697
+ onChange: (next) => write("styleEmojiHint", next),
1698
+ })
1699
+ )
1700
+ ),
1701
+
1702
+ /* ── B:角色工作室 ── */
1703
+ h(
1704
+ Section,
1705
+ {
1706
+ ...sectionProps("studio"),
1707
+ title: t("studio"),
1708
+ hint: t("studioHint"),
1709
+ summary: [`${t("character")}:${activeCharacter?.name || activeCharacter?.id || t("unknownCharacter")}`],
1710
+ },
1711
+ h(
1712
+ "div",
1713
+ { className: "tlrp-row" },
1714
+ h(
1715
+ "span",
1716
+ { style: { minWidth: 220, flex: "0 1 300px" } },
1717
+ h(Select, {
1718
+ value: activeId,
1719
+ disabled,
1720
+ options: characterOptions.length > 0 ? characterOptions : [{ value: "", label: t("unknownCharacter") }],
1721
+ onChange: (next) => write("activeCharacterId", next),
1722
+ })
1723
+ ),
1724
+ h(Button, { disabled, onClick: createCharacter }, t("newCharacter")),
1725
+ h(Button, { disabled, onClick: duplicateCharacter }, t("duplicateCharacter")),
1726
+ h(Button, { disabled, danger: true, onClick: removeCharacter }, t("removeCharacter")),
1727
+ h(Button, { disabled, active: dirty, onClick: saveCharacter }, t("saveCharacter"))
1728
+ ),
1729
+ dirty ? h("p", { className: "tlrp-note" }, t("unsavedHint")) : null,
1730
+ h(
1731
+ "div",
1732
+ { className: "tlrp-grid" },
1733
+ CHAR_FIELDS.map((field) =>
1734
+ h(
1735
+ Field,
1736
+ { key: field.key, label: t(`field_${field.key}`), hint: t(`hint_${field.key}`) },
1737
+ field.area
1738
+ ? h(TextArea, {
1739
+ rows: field.rows,
1740
+ value: draft[field.key] ?? "",
1741
+ disabled,
1742
+ onChange: (next) => patchDraft(field.key, next),
1743
+ })
1744
+ : h(TextInput, {
1745
+ value: draft[field.key] ?? "",
1746
+ disabled,
1747
+ onChange: (next) => patchDraft(field.key, next),
1748
+ })
1749
+ )
1750
+ )
1751
+ )
1752
+ ),
1753
+
1754
+ /* ── C:触发词映射表 ── */
1755
+ h(
1756
+ Section,
1757
+ {
1758
+ ...sectionProps("triggers"),
1759
+ title: t("triggers"),
1760
+ hint: t("triggersHint"),
1761
+ summary: [t("sectionsSummaryTriggers", { count: localTriggers.length })],
1762
+ },
1763
+ h(
1764
+ "div",
1765
+ { className: "tlrp-row" },
1766
+ h(Button, { disabled, onClick: addTrigger }, t("addTrigger")),
1767
+ h(
1768
+ "span",
1769
+ { style: { minWidth: 200 } },
1770
+ h(Select, {
1771
+ value: triggerFilter,
1772
+ disabled,
1773
+ options: [
1774
+ { value: "all", label: `${t("filterAll")}(${localTriggers.length})` },
1775
+ { value: "global", label: t("filterGlobal") },
1776
+ ...characterOptions.map((option) => ({ value: option.value, label: option.label })),
1777
+ ],
1778
+ onChange: setTriggerFilter,
1779
+ })
1780
+ )
1781
+ ),
1782
+ visibleTriggers.length === 0
1783
+ ? h("p", { className: "tlrp-note" }, t("emptyTriggers"))
1784
+ : visibleTriggers.map((entry) =>
1785
+ h(
1786
+ "div",
1787
+ { className: "tlrp-card", key: entry?.id ?? mintId() },
1788
+ h(
1789
+ "div",
1790
+ { className: "tlrp-card-head" },
1791
+ h(Toggle, {
1792
+ label: t("triggerEnabled"),
1793
+ checked: entry?.enabled !== false,
1794
+ disabled,
1795
+ onChange: (next) => patchTrigger(entry.id, { enabled: next }),
1796
+ }),
1797
+ h(
1798
+ "span",
1799
+ { style: { minWidth: 160, flex: "0 1 220px" } },
1800
+ h(TextInput, {
1801
+ value: entry?.characterId ?? "",
1802
+ placeholder: t("triggerCharacterGlobal"),
1803
+ disabled,
1804
+ onChange: (next) => patchTrigger(entry.id, { characterId: next.trim() }),
1805
+ })
1806
+ ),
1807
+ h(Button, { danger: true, disabled, onClick: () => removeTrigger(entry.id) }, t("removeTrigger"))
1808
+ ),
1809
+ h(
1810
+ Field,
1811
+ { label: t("triggerKeywords"), hint: t("triggerKeywordsHint") },
1812
+ h(TextInput, {
1813
+ value: keywordsToText(entry?.keywords),
1814
+ disabled,
1815
+ onChange: (next) => patchTrigger(entry.id, { keywords: textToKeywords(next) }),
1816
+ })
1817
+ ),
1818
+ h(
1819
+ Field,
1820
+ { label: t("triggerInject") },
1821
+ h(TextArea, {
1822
+ rows: 4,
1823
+ value: entry?.injectText ?? "",
1824
+ disabled,
1825
+ onChange: (next) => patchTrigger(entry.id, { injectText: next }),
1826
+ })
1827
+ ),
1828
+ h(
1829
+ "div",
1830
+ { className: "tlrp-row" },
1831
+ h(
1832
+ "span",
1833
+ { style: { width: 120 } },
1834
+ h(Field, { label: t("triggerPriority") }, h(TextInput, {
1835
+ value: String(entry?.priority ?? 50),
1836
+ disabled,
1837
+ onChange: (next) => {
1838
+ const parsed = Number(next);
1839
+ patchTrigger(entry.id, { priority: Number.isFinite(parsed) ? parsed : 0 });
1840
+ },
1841
+ }))
1842
+ ),
1843
+ h("span", { className: "tlrp-chip" }, entry?.id ?? "")
1844
+ )
1845
+ )
1846
+ )
1847
+ ),
1848
+
1849
+ /* ── D:预设导入导出与调试探针 ── */
1850
+ h(
1851
+ Section,
1852
+ { ...sectionProps("presets"), title: t("presets"), hint: t("presetsHint") },
1853
+ h(
1854
+ "div",
1855
+ { className: "tlrp-grid" },
1856
+ h(
1857
+ Field,
1858
+ { label: t("exportJson"), hint: t("exportHint") },
1859
+ h(TextArea, { rows: 6, value: exportJson, disabled: true, onChange: () => {} }),
1860
+ h(Button, { disabled, onClick: downloadJson }, t("download"))
1861
+ ),
1862
+ h(
1863
+ Field,
1864
+ { label: t("importJson"), hint: t("importHint") },
1865
+ h(TextArea, { rows: 6, value: importText, disabled, onChange: setImportText }),
1866
+ h(Button, { disabled, onClick: applyImport }, t("importApply"))
1867
+ )
1868
+ ),
1869
+ h(
1870
+ "details",
1871
+ { className: "tlrp-details" },
1872
+ h("summary", null, t("preview")),
1873
+ h("p", { className: "tlrp-note" }, t("previewHint")),
1874
+ h(
1875
+ "div",
1876
+ { className: "tlrp-preview" },
1877
+ h(
1878
+ "div",
1879
+ null,
1880
+ h("div", { className: "tlrp-field-label" }, t("previewCharacter")),
1881
+ h("pre", { className: "tlrp-mono tlrp-card" }, previewCharacter)
1882
+ ),
1883
+ h(
1884
+ "div",
1885
+ null,
1886
+ h("div", { className: "tlrp-field-label" }, t("previewDirective")),
1887
+ h(
1888
+ "pre",
1889
+ { className: "tlrp-mono tlrp-card" },
1890
+ [`## 形态指令(当前模式:${mode === "auto" ? "auto" : mode})`, ...directiveLines].join("\n")
1891
+ )
1892
+ )
1893
+ ),
1894
+ h("p", { className: "tlrp-note" }, t("previewLocalNote"))
1895
+ )
1896
+ )
1897
+ );
1898
+ }
1899
+
1900
+ /** 兜底日志:客户端上下文可能没有 logger。 */
1901
+ function ctxLog(error) {
1902
+ try {
1903
+ console.warn("[tlrp]", error);
1904
+ } catch {
1905
+ /* 忽略 */
1906
+ }
1907
+ }
1908
+
1909
+ /* ─────────────────────── 卡片与栏目 ─────────────────────── */
1910
+
1911
+ /** 插件页上的配置卡:summary 只给一行说明,page 给完整工作室。 */
1912
+ function TlrpSettingsCard(props) {
1913
+ if (props.view === "summary") return props.t("description");
1914
+ return h(TlrpStudio, props);
1915
+ }
1916
+
1917
+ /** 设置侧边栏栏目:标题 + 说明 + 工作室。 */
1918
+ function TlrpSettingsSection(props) {
1919
+ return h(
1920
+ "div",
1921
+ null,
1922
+ h("h2", { className: "tlrp-title" }, props.t("title")),
1923
+ h("p", { className: "tlrp-desc" }, props.t("description")),
1924
+ h(TlrpStudio, props)
1925
+ );
1926
+ }
1927
+
1928
+ /* ─────────────────────── 挂载 ─────────────────────── */
1929
+
1930
+ /** 本插件消费的客户端服务。configForms 另行按可选子作用域探测。 */
1931
+ const inject = ["slots", "locale"];
1932
+
1933
+ /**
1934
+ * 从宿主镜像的命名空间目录里挑出本插件的配置命名空间。
1935
+ * @param forms - configForms 服务。
1936
+ * @param served - whileServed 给出的已服出集合。
1937
+ * @returns 命名空间 id。
1938
+ */
1939
+ function pickNamespace(forms, served) {
1940
+ let listed = [];
1941
+ try {
1942
+ listed = forms.describe().getSnapshot().view?.namespaces ?? [];
1943
+ } catch {
1944
+ listed = [];
1945
+ }
1946
+ const names = listed
1947
+ .map((entry) => (typeof entry?.ns === "string" ? entry.ns : typeof entry?.nsId === "string" ? entry.nsId : undefined))
1948
+ .filter((ns) => typeof ns === "string");
1949
+ return (
1950
+ names.find((ns) => ns === ROW_ID) ??
1951
+ names.find((ns) => ns === `include:${ROW_ID}`) ??
1952
+ names.find((ns) => ns === BUNDLE_NAME) ??
1953
+ NAMESPACE_CANDIDATES.find((ns) => (served?.has ? served.has(ns) : true)) ??
1954
+ NAMESPACE_CANDIDATES[0]
1955
+ );
1956
+ }
1957
+
1958
+ /**
1959
+ * 挂载客户端半边:字典、三处槽位注册。页面只在宿主确实服出 tlrp 命名空间时出现。
1960
+ * @param ctx - 浏览器插件上下文。
1961
+ */
1962
+ function apply(ctx) {
1963
+ injectCss();
1964
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "tlrp: settings dictionaries");
1965
+ const t = ctx.locale.bind(NS);
1966
+
1967
+ /**
1968
+ * 一旦宿主服出任一候选命名空间,就注册配置卡。
1969
+ * @param forms - configForms 服务。
1970
+ * @returns 注册函数,交给 whileServed。
1971
+ */
1972
+ function registerCard(forms) {
1973
+ return (served) => {
1974
+ const namespace = pickNamespace(forms, served);
1975
+ const controller = forms.get(namespace);
1976
+ const store = {
1977
+ getSnapshot: () => controller.getSnapshot(),
1978
+ subscribe: (listener) => controller.subscribe(listener),
1979
+ };
1980
+ const face = () => ({
1981
+ hooks: { tlrp: store },
1982
+ set: (field, next) => {
1983
+ Promise.resolve(controller.set(field, next)).catch((error) => ctxLog(error));
1984
+ },
1985
+ unset: (field) => {
1986
+ Promise.resolve(controller.unset(field)).catch((error) => ctxLog(error));
1987
+ },
1988
+ });
1989
+ // 插件页分两处渲染同一张卡:bundle 页(键 = 包名)与
1990
+ // bundle 声明的行配置页(键 = <包名>#<行 id>)。
1991
+ const cells = [
1992
+ { name: "plugins.bundle.config", key: BUNDLE_NAME },
1993
+ { name: "plugins.row.config", key: ROW_CONFIG_KEY },
1994
+ ];
1995
+ const offs = [];
1996
+ for (const cell of cells) {
1997
+ try {
1998
+ const off = ctx.slots.inject(cell.name, () =>
1999
+ ctx.slots.register(
2000
+ {
2001
+ name: cell.name,
2002
+ key: cell.key,
2003
+ priority: 30,
2004
+ locale: NS,
2005
+ inject: face,
2006
+ },
2007
+ TlrpSettingsCard
2008
+ )
2009
+ );
2010
+ if (typeof off === "function") offs.push(off);
2011
+ } catch (error) {
2012
+ ctxLog(error);
2013
+ }
2014
+ }
2015
+ // 设置侧边栏的独立栏目:插件页那两处槽位只长在「插件管理」里,
2016
+ // 想让用户点一下「设置」就能看见,必须自己占一个 settings.section。
2017
+ try {
2018
+ const off = ctx.slots.inject("settings.section", () =>
2019
+ ctx.slots.register(
2020
+ {
2021
+ name: "settings.section",
2022
+ id: SECTION_ID,
2023
+ order: SECTION_ORDER,
2024
+ label: () => t("sectionLabel"),
2025
+ locale: NS,
2026
+ inject: face,
2027
+ },
2028
+ TlrpSettingsSection
2029
+ )
2030
+ );
2031
+ if (typeof off === "function") offs.push(off);
2032
+ } catch (error) {
2033
+ ctxLog(error);
2034
+ }
2035
+ return () => offs.forEach((off) => off());
2036
+ };
2037
+ }
2038
+
2039
+ // configForms 是可选服务:旧宿主没有它就不出配置卡,插件本体照常工作。
2040
+ let disposer = () => {};
2041
+ const bind = (forms) => {
2042
+ if (forms === undefined || forms === null || typeof forms.whileServed !== "function") return;
2043
+ disposer = forms.whileServed(NAMESPACE_CANDIDATES, registerCard(forms));
2044
+ };
2045
+ // cordis 的 inject(deps, callback) 会把 callback 当作子插件体调用,
2046
+ // 收到的是**子上下文**(ctx, config),服务挂在 ctx.<name> 上,
2047
+ // 不是一张服务表 —— 所以要从 inner.configForms 取。
2048
+ if (typeof ctx.inject === "function") {
2049
+ ctx.inject(["configForms"], (inner) => {
2050
+ bind(inner?.configForms);
2051
+ });
2052
+ } else {
2053
+ bind();
2054
+ }
2055
+ ctx.effect(() => () => {
2056
+ if (typeof disposer === "function") disposer();
2057
+ }, "tlrp: settings page");
2058
+ }
2059
+
2060
+ exports.NS = NS;
2061
+ exports.NAMESPACE_CANDIDATES = NAMESPACE_CANDIDATES;
2062
+ exports.apply = apply;
2063
+ exports.inject = inject;
2064
+ exports.TlrpStudio = TlrpStudio;
2065
+ exports.TlrpSettingsCard = TlrpSettingsCard;
2066
+ exports.TlrpSettingsSection = TlrpSettingsSection;
2067
+ exports.assembleCharacterBlock = assembleCharacterBlock;
2068
+ exports.EMPTY_MODE = EMPTY_MODE;
2069
+ exports.TOOL_POLICY_OPTIONS = TOOL_POLICY_OPTIONS;
2070
+ exports.normalizeModeList = normalizeModeList;
2071
+ exports.normalizeModeEntry = normalizeModeEntry;
2072
+ exports.textToAliases = textToAliases;
2073
+ exports.aliasesToText = aliasesToText;
2074
+ exports.toolPolicyLabel = toolPolicyLabel;
2075
+ exports.ROW_CONFIG_KEY = ROW_CONFIG_KEY;
2076
+ exports.SECTION_ID = SECTION_ID;
2077
+ return module.exports;
2078
+ },
2079
+ });