dsh-persona-forge 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -2
- package/README.en.md +518 -0
- package/README.md +200 -248
- package/cards/muscle-crew.yml +3 -3
- package/lib/client.js +490 -42
- package/lib/index.js +16 -3
- package/lib/preserve.js +138 -0
- package/lib/prompts.js +164 -3
- package/lib/routes.js +169 -9
- package/lib/shared/protocol.js +10 -0
- package/lib/store.js +24 -2
- package/package.json +3 -3
- package/README.zh-CN.md +0 -297
package/lib/client.js
CHANGED
|
@@ -38,8 +38,10 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
'control.busy': '改写中…',
|
|
39
39
|
'control.menu.title': '选择角色',
|
|
40
40
|
'control.menu.empty': '还没有角色卡。把 .yml 文件放进角色卡目录即可添加。',
|
|
41
|
-
'control.menu.manage': '管理角色卡…',
|
|
42
41
|
'control.menu.reveal': '打开角色卡目录',
|
|
42
|
+
'control.more': '更多设置',
|
|
43
|
+
'control.more.open': '展开改写方式、保真度等按会话生效的设置',
|
|
44
|
+
'control.more.custom': '已改:',
|
|
43
45
|
'control.intensity': '强度',
|
|
44
46
|
'control.intensity.light': '轻',
|
|
45
47
|
'control.intensity.medium': '中',
|
|
@@ -50,6 +52,16 @@ window.__ModuleLoader__.load({
|
|
|
50
52
|
'control.sendMode.direct': '直接发出',
|
|
51
53
|
'control.sendMode.hint.review': '改写结果先在下方的对比面板显示,你确认后再发送;原草稿不受影响。',
|
|
52
54
|
'control.sendMode.hint.direct': '改写完成后立即作为你的消息发出,不再确认。',
|
|
55
|
+
'control.mode': '改写方式',
|
|
56
|
+
'control.mode.card': '跟随卡片',
|
|
57
|
+
'control.mode.llm': '模型改写',
|
|
58
|
+
'control.mode.template': '模板套用',
|
|
59
|
+
'control.mode.hint': '跟随卡片 = 用卡片自己声明的方式。临时切换只影响这个会话。',
|
|
60
|
+
'control.fidelity': '保真度',
|
|
61
|
+
'control.fidelity.card': '跟随卡片',
|
|
62
|
+
'control.fidelity.style': '只改语气',
|
|
63
|
+
'control.fidelity.hint.style': '这张卡声明的是「只改语气」,不能在这里放宽——放宽是改卡的属性,请在设置页改。',
|
|
64
|
+
'control.fidelity.hint.strategy': '这张卡允许追加行为约束。这里可以临时收窄成「只改语气」,但收不回去。',
|
|
53
65
|
'panel.title': '角色改写',
|
|
54
66
|
'panel.loading': '正在按「{name}」改写…',
|
|
55
67
|
'panel.original': '你写的',
|
|
@@ -64,13 +76,17 @@ window.__ModuleLoader__.load({
|
|
|
64
76
|
'panel.sent.hint': '消息已发出,无法撤回。下面是实际发出的内容。',
|
|
65
77
|
'panel.meta': '{provider} / {model} · {ms}ms · 强度 {intensity}',
|
|
66
78
|
'panel.template': '模板模式(未调用模型)',
|
|
79
|
+
'panel.slow': '已经 {seconds} 秒了 —— 模型可能较慢。再等一会儿通常会有结果;若一直不动,可关闭面板后重试。',
|
|
67
80
|
'panel.drift': '技术事实可能被改动',
|
|
68
81
|
'panel.checkUnavailable': '未完成事实校验',
|
|
69
82
|
'panel.checkOk': '技术事实已保留',
|
|
83
|
+
'panel.factsDrift': '检测到技术内容变动',
|
|
84
|
+
'panel.factsMissing': '丢失:',
|
|
85
|
+
'panel.factsAdded': '新增:',
|
|
86
|
+
'panel.factsOnly': '已做本地检查(不耗 token):标识符、路径、版本号、数字均未丢失。但它看不出「需求被悄悄改弱」或「用散文追加了约束」——要覆盖这些请开启模型校验。',
|
|
70
87
|
'panel.stale': '草稿在改写期间有改动——结果基于改写前的文本。',
|
|
71
88
|
'error.empty': '输入框是空的,先写下你的需求。',
|
|
72
89
|
'error.phase': '输入框正忙(提交中),请稍后再试。',
|
|
73
|
-
'error.busy': '已有一个改写在进行中。',
|
|
74
90
|
'error.noCard': '请先选择一个角色。',
|
|
75
91
|
'error.unconfigured': '无法确定模型:先在本会话发一条消息,或设置一个默认模型。',
|
|
76
92
|
'error.timeout': '改写超时,请重试;原输入未改动。',
|
|
@@ -82,11 +98,23 @@ window.__ModuleLoader__.load({
|
|
|
82
98
|
'settings.dir': '角色卡目录',
|
|
83
99
|
'settings.reveal': '打开目录',
|
|
84
100
|
'settings.new': '新建角色卡',
|
|
85
|
-
'settings.
|
|
86
|
-
'settings.
|
|
101
|
+
'settings.ai': 'AI 代填',
|
|
102
|
+
'settings.ai.tip': '新建角色卡时可以让 AI 代填:写一句角色思路,风格和四档范例都会自动写好。',
|
|
103
|
+
'settings.ai.model': '使用模型:{provider} / {model}(设置页没有会话上下文,用的是 harness 默认模型;改写本身跟随当前会话的模型)',
|
|
104
|
+
'settings.ai.desc': '用一句话描述角色的思路,AI 会按同一套规范把表单填好:风格、四档强度的转换范例都写好。生成结果只填入表单,不会自动保存。',
|
|
105
|
+
'settings.ai.briefPlaceholder': '例如:一个脾气暴躁但极其可靠的老灯塔守夜人,说话简短、爱用航海比喻',
|
|
106
|
+
'settings.ai.generate': '生成草稿',
|
|
107
|
+
'settings.ai.busy': 'AI 正在撰写角色卡…',
|
|
108
|
+
'settings.ai.hint': '生成的是草稿,改完再保存。',
|
|
109
|
+
'settings.ai.needBrief': '请先写下角色的思路。',
|
|
110
|
+
'settings.ai.failed': '生成失败:{message}',
|
|
111
|
+
'settings.origin.builtin': '内置',
|
|
112
|
+
'settings.origin.edited': '已修改',
|
|
113
|
+
'settings.origin.custom': '自定义',
|
|
87
114
|
'settings.edit': '编辑',
|
|
88
115
|
'settings.delete': '删除',
|
|
89
116
|
'settings.deleteConfirm': '确定删除这张角色卡?',
|
|
117
|
+
'settings.deleteEditedConfirm': '这张卡改自内置版本。删除会丢弃你的修改,恢复成内置版本。继续?',
|
|
90
118
|
'settings.reset': '还原为内置',
|
|
91
119
|
'settings.resetConfirm': '用插件内置的版本覆盖这张卡?你对它的本地修改会丢失。',
|
|
92
120
|
'settings.resetHint': '把这张卡恢复成插件内置的版本,丢弃本地修改。',
|
|
@@ -95,7 +123,6 @@ window.__ModuleLoader__.load({
|
|
|
95
123
|
'settings.check.none': '所有角色卡都与内置版本一致。',
|
|
96
124
|
'settings.check.some': '有 {n} 张角色卡与内置版本不一致:',
|
|
97
125
|
'settings.check.desc': '点一次即可查出哪些卡偏离了内置版本。只有不一致的卡才显示「还原为内置」。',
|
|
98
|
-
'settings.changed': '改动:',
|
|
99
126
|
'settings.save': '保存',
|
|
100
127
|
'settings.cancel': '取消',
|
|
101
128
|
'settings.saved': '已保存',
|
|
@@ -115,6 +142,9 @@ window.__ModuleLoader__.load({
|
|
|
115
142
|
'settings.field.style': '角色风格描述(给改写模型的指令)',
|
|
116
143
|
'settings.field.template': '模板(用 {{input}} 代表你的原话)',
|
|
117
144
|
'settings.field.examples': '转换范例(每行一条,格式:原话 => 改写后)',
|
|
145
|
+
'settings.field.byIntensity': '分强度范例(每档各一组;留空则回退到上面的通用范例)',
|
|
146
|
+
'settings.field.byIntensity.empty': '留空 = 这一档沿用上面的通用范例',
|
|
147
|
+
'settings.hint.byIntensity': '四档强度各自用哪组范例。范例越贴合该档语气,改写越稳定。留空的档位会回退到通用范例。',
|
|
118
148
|
'settings.hint.fidelity': '只改语气:只换说法,需求一字不动。允许增加行为约束:改写时还能追加符合角色的做事风格(例如「别做冗长规划」)——这会真的改变你的需求,建议配合「先审查」。',
|
|
119
149
|
'settings.hint.examples': '范例是最强的风格锚点。每条范例里原话的技术事实必须出现在改写结果中——否则模型会学会"用氛围替换需求"。',
|
|
120
150
|
'settings.diagnostics': '加载问题',
|
|
@@ -128,8 +158,10 @@ window.__ModuleLoader__.load({
|
|
|
128
158
|
'control.busy': 'Rewriting…',
|
|
129
159
|
'control.menu.title': 'Choose a character',
|
|
130
160
|
'control.menu.empty': 'No character cards yet. Drop a .yml file into the card directory to add one.',
|
|
131
|
-
'control.menu.manage': 'Manage cards…',
|
|
132
161
|
'control.menu.reveal': 'Open card directory',
|
|
162
|
+
'control.more': 'More settings',
|
|
163
|
+
'control.more.open': 'Show rewrite mode, fidelity and other per-session settings',
|
|
164
|
+
'control.more.custom': 'Changed: ',
|
|
133
165
|
'control.intensity': 'Intensity',
|
|
134
166
|
'control.intensity.light': 'Light',
|
|
135
167
|
'control.intensity.medium': 'Medium',
|
|
@@ -140,6 +172,16 @@ window.__ModuleLoader__.load({
|
|
|
140
172
|
'control.sendMode.direct': 'Send directly',
|
|
141
173
|
'control.sendMode.hint.review': 'The rewrite appears in the comparison panel below; you confirm before it is sent, and your draft is untouched.',
|
|
142
174
|
'control.sendMode.hint.direct': 'The rewrite is sent as your message immediately, without confirmation.',
|
|
175
|
+
'control.mode': 'Rewrite mode',
|
|
176
|
+
'control.mode.card': 'Card default',
|
|
177
|
+
'control.mode.llm': 'Model rewrite',
|
|
178
|
+
'control.mode.template': 'Template',
|
|
179
|
+
'control.mode.hint': 'Card default = use whatever the card declares. A temporary switch applies to this session only.',
|
|
180
|
+
'control.fidelity': 'Fidelity',
|
|
181
|
+
'control.fidelity.card': 'Card default',
|
|
182
|
+
'control.fidelity.style': 'Voice only',
|
|
183
|
+
'control.fidelity.hint.style': 'This card declares voice-only, and it cannot be widened here — widening is a property of the card, so change it in the settings page.',
|
|
184
|
+
'control.fidelity.hint.strategy': 'This card may add behavioural constraints. You can narrow it to voice-only for this session, but not widen it back.',
|
|
143
185
|
'panel.title': 'Persona rewrite',
|
|
144
186
|
'panel.loading': 'Rewriting as “{name}”…',
|
|
145
187
|
'panel.original': 'You wrote',
|
|
@@ -154,13 +196,17 @@ window.__ModuleLoader__.load({
|
|
|
154
196
|
'panel.sent.hint': 'The message is already sent and cannot be recalled. This is what was sent.',
|
|
155
197
|
'panel.meta': '{provider} / {model} · {ms}ms · intensity {intensity}',
|
|
156
198
|
'panel.template': 'Template mode (no model call)',
|
|
199
|
+
'panel.slow': '{seconds} s and counting — the model may just be slow. It usually returns; if it never does, close the panel and retry.',
|
|
157
200
|
'panel.drift': 'Technical facts may have changed',
|
|
158
201
|
'panel.checkUnavailable': 'Preservation check did not complete',
|
|
159
202
|
'panel.checkOk': 'Technical facts preserved',
|
|
203
|
+
'panel.factsDrift': 'Technical content changed',
|
|
204
|
+
'panel.factsMissing': 'Lost: ',
|
|
205
|
+
'panel.factsAdded': 'Added: ',
|
|
206
|
+
'panel.factsOnly': 'Local check only (no tokens spent): no identifier, path, version or number was lost. It cannot see a requirement quietly weakened, or a constraint added in prose — turn on the model check for those.',
|
|
160
207
|
'panel.stale': 'The draft changed while rewriting — the result is based on the earlier text.',
|
|
161
208
|
'error.empty': 'The input box is empty — write your request first.',
|
|
162
209
|
'error.phase': 'The input box is busy (submitting) — try again shortly.',
|
|
163
|
-
'error.busy': 'A rewrite is already running.',
|
|
164
210
|
'error.noCard': 'Choose a character first.',
|
|
165
211
|
'error.unconfigured': 'No model resolved: send a message in this session first, or set a default model.',
|
|
166
212
|
'error.timeout': 'The rewrite timed out. Retry; your draft is untouched.',
|
|
@@ -172,11 +218,23 @@ window.__ModuleLoader__.load({
|
|
|
172
218
|
'settings.dir': 'Card directory',
|
|
173
219
|
'settings.reveal': 'Open directory',
|
|
174
220
|
'settings.new': 'New character card',
|
|
175
|
-
'settings.
|
|
176
|
-
'settings.
|
|
221
|
+
'settings.ai': 'AI draft',
|
|
222
|
+
'settings.ai.tip': 'You can have the AI draft a card for you: describe the character in a sentence and the style text plus all four intensity example sets are written for you.',
|
|
223
|
+
'settings.ai.model': 'Model: {provider} / {model} (the settings page has no session context, so this is the harness default; the rewrite itself follows the current session\'s model)',
|
|
224
|
+
'settings.ai.desc': 'Describe the character in a sentence and the AI fills the form for you: the style text and conversion examples for all four intensity levels. The result only fills the form — it is never saved automatically.',
|
|
225
|
+
'settings.ai.briefPlaceholder': 'e.g. a grumpy but utterly reliable old lighthouse keeper who speaks in short sentences and nautical metaphors',
|
|
226
|
+
'settings.ai.generate': 'Generate draft',
|
|
227
|
+
'settings.ai.busy': 'Writing the character card…',
|
|
228
|
+
'settings.ai.hint': 'This is a draft — edit it before saving.',
|
|
229
|
+
'settings.ai.needBrief': 'Write the character idea first.',
|
|
230
|
+
'settings.ai.failed': 'Generation failed: {message}',
|
|
231
|
+
'settings.origin.builtin': 'Built-in',
|
|
232
|
+
'settings.origin.edited': 'Edited',
|
|
233
|
+
'settings.origin.custom': 'Custom',
|
|
177
234
|
'settings.edit': 'Edit',
|
|
178
235
|
'settings.delete': 'Delete',
|
|
179
236
|
'settings.deleteConfirm': 'Delete this character card?',
|
|
237
|
+
'settings.deleteEditedConfirm': 'This card is an edit of a bundled one. Deleting it discards your changes and restores the bundled version. Continue?',
|
|
180
238
|
'settings.reset': 'Restore bundled',
|
|
181
239
|
'settings.resetConfirm': 'Replace this card with the bundled version? Your local edits to it will be lost.',
|
|
182
240
|
'settings.resetHint': 'Restore this card to the version the plugin ships, discarding local edits.',
|
|
@@ -185,7 +243,6 @@ window.__ModuleLoader__.load({
|
|
|
185
243
|
'settings.check.none': 'Every character card matches its bundled version.',
|
|
186
244
|
'settings.check.some': '{n} card(s) differ from the bundled version:',
|
|
187
245
|
'settings.check.desc': 'Finds which cards have drifted from the bundled version. Only a drifted card shows a Restore bundled action.',
|
|
188
|
-
'settings.changed': 'Changed:',
|
|
189
246
|
'settings.save': 'Save',
|
|
190
247
|
'settings.cancel': 'Cancel',
|
|
191
248
|
'settings.saved': 'Saved',
|
|
@@ -205,6 +262,9 @@ window.__ModuleLoader__.load({
|
|
|
205
262
|
'settings.field.style': 'Character style (instructions for the rewriting model)',
|
|
206
263
|
'settings.field.template': 'Template (use {{input}} for the user\'s text)',
|
|
207
264
|
'settings.field.examples': 'Conversion examples (one per line, format: original => rewritten)',
|
|
265
|
+
'settings.field.byIntensity': 'Per-intensity examples (one set each; empty falls back to the list above)',
|
|
266
|
+
'settings.field.byIntensity.empty': 'Empty = this level reuses the examples above',
|
|
267
|
+
'settings.hint.byIntensity': 'Which demonstrations each intensity level uses. The closer a set matches that level\'s tone, the steadier the rewrite. A level left empty falls back to the shared list.',
|
|
208
268
|
'settings.hint.fidelity': 'Voice only: wording changes, the request does not. May add behavioural constraints: the rewrite can also add the character\'s working style (e.g. "no long planning") — that genuinely changes your request, so pair it with Review first.',
|
|
209
269
|
'settings.hint.examples': 'Examples are the strongest style anchor. Every technical fact in an example\'s input must appear in its output — otherwise the model learns to replace requirements with atmosphere.',
|
|
210
270
|
'settings.diagnostics': 'Load problems',
|
|
@@ -265,6 +325,15 @@ window.__ModuleLoader__.load({
|
|
|
265
325
|
.pf-menu{position:fixed;z-index:1100;box-sizing:border-box;width:max-content;min-width:300px;max-width:min(460px,100vw - 24px);max-height:min(70vh,560px);overflow-y:auto;padding:4px;border:0;border-radius:var(--dsw-radius-lg);background:var(--dsw-menu-surface-fill,var(--dsw-alias-bg-overlay));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));color:var(--dsw-alias-label-primary);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
|
|
266
326
|
.pf-menu-title{padding:6px 8px 4px;font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}
|
|
267
327
|
.pf-menu-sep{height:.5px;margin:3px 2px;background:var(--dsw-alias-border-l2)}
|
|
328
|
+
/* The fold for advanced settings: a quiet row, not another equal-weight control. */
|
|
329
|
+
.pf-more{color:var(--dsw-alias-label-secondary)}
|
|
330
|
+
.pf-more-body{padding:2px 8px 8px;display:grid;gap:6px}
|
|
331
|
+
.pf-pref-row{display:flex;align-items:center;gap:6px;cursor:pointer}
|
|
332
|
+
.pf-pref-row input{margin:0}
|
|
333
|
+
/* The live counter during a rewrite: a muted suffix so the wait reads as
|
|
334
|
+
* progress rather than a freeze. */
|
|
335
|
+
.pf-elapsed{margin-left:8px;padding:0 6px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
|
|
336
|
+
|
|
268
337
|
.pf-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-primary);text-align:left;font:inherit;font-size:13px;line-height:20px;cursor:pointer}
|
|
269
338
|
.pf-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
270
339
|
.pf-item:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}
|
|
@@ -327,6 +396,11 @@ window.__ModuleLoader__.load({
|
|
|
327
396
|
.pf-card-desc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);word-break:break-word}
|
|
328
397
|
.pf-tag{font-size:10px;line-height:15px;padding:1px 5px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
329
398
|
.pf-form{display:grid;gap:10px;max-width:720px;padding:12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2)}
|
|
399
|
+
/* The AI box inside the new-card form: a quiet inset panel, not a second
|
|
400
|
+
* competing call to action. */
|
|
401
|
+
.pf-ai{border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);overflow:hidden}
|
|
402
|
+
.pf-ai-head{display:flex;align-items:center;gap:6px;width:100%;padding:7px 10px;border:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;font-weight:500;text-align:left;cursor:pointer}
|
|
403
|
+
.pf-ai-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
330
404
|
.pf-field{display:grid;gap:4px}
|
|
331
405
|
.pf-field label{font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}
|
|
332
406
|
.pf-field input,.pf-field select,.pf-field textarea{width:100%;box-sizing:border-box;padding:6px 8px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;outline:none}
|
|
@@ -393,6 +467,7 @@ window.__ModuleLoader__.load({
|
|
|
393
467
|
const resetCard = (id) => postJson(`${PREFIX}/cards/reset`, { id })
|
|
394
468
|
const diffCards = () => postJson(`${PREFIX}/cards/diff`, {})
|
|
395
469
|
const revealDir = () => postJson(`${PREFIX}/reveal`, {})
|
|
470
|
+
const draftCard = (payload) => postJson(`${PREFIX}/cards/draft`, payload)
|
|
396
471
|
|
|
397
472
|
// ---------------------------------------------------------------------
|
|
398
473
|
// Shared UI state
|
|
@@ -424,6 +499,26 @@ window.__ModuleLoader__.load({
|
|
|
424
499
|
const sendModeKey = (key) => `persona-forge:sendMode:${key}`
|
|
425
500
|
const cardKey = (key) => `persona-forge:card:${key}`
|
|
426
501
|
const intensityKey = (key) => `persona-forge:intensity:${key}`
|
|
502
|
+
const modeKey = (key) => `persona-forge:mode:${key}`
|
|
503
|
+
const fidelityKey = (key) => `persona-forge:fidelity:${key}`
|
|
504
|
+
/** Effective mode for a card: the session's pick when the card can honour it, else the card's. */
|
|
505
|
+
function effectiveModeOf(card, override) {
|
|
506
|
+
if (card === null) return 'llm'
|
|
507
|
+
if (override === 'template') return typeof card.template === 'string' && card.template.trim() !== '' ? 'template' : card.mode
|
|
508
|
+
if (override === 'llm') return typeof card.style === 'string' && card.style.trim() !== '' ? 'llm' : card.mode
|
|
509
|
+
return card.mode
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Effective fidelity for a card: the session may NARROW a strategy card to
|
|
514
|
+
* voice-only, never widen a voice-only card to strategy. The server enforces
|
|
515
|
+
* the same rule; this keeps the UI from offering a choice it would ignore.
|
|
516
|
+
*/
|
|
517
|
+
function effectiveFidelityOf(card, override) {
|
|
518
|
+
if (card === null) return 'style'
|
|
519
|
+
if (card.fidelity === 'strategy' && override === 'style') return 'style'
|
|
520
|
+
return card.fidelity
|
|
521
|
+
}
|
|
427
522
|
|
|
428
523
|
/**
|
|
429
524
|
* The "no persona" selection. It is a real, persisted state rather than the
|
|
@@ -511,7 +606,11 @@ window.__ModuleLoader__.load({
|
|
|
511
606
|
diagnostics: [],
|
|
512
607
|
directory: '',
|
|
513
608
|
hostSendMode: 'review',
|
|
514
|
-
|
|
609
|
+
// Mirrors the host default in lib/index.js: the model check is opt-in
|
|
610
|
+
// because it is paid for on every send, and the local check always runs.
|
|
611
|
+
factCheck: false,
|
|
612
|
+
/** Which model an AI draft would use, as a ready-to-render sentence. */
|
|
613
|
+
draftModelLabel: '',
|
|
515
614
|
panel: null,
|
|
516
615
|
}
|
|
517
616
|
function notify(next) {
|
|
@@ -539,7 +638,13 @@ window.__ModuleLoader__.load({
|
|
|
539
638
|
diagnostics: Array.isArray(value.diagnostics) ? value.diagnostics : [],
|
|
540
639
|
directory: typeof value.directory === 'string' ? value.directory : '',
|
|
541
640
|
hostSendMode: value.sendMode === 'direct' ? 'direct' : 'review',
|
|
542
|
-
factCheck: value.factCheck
|
|
641
|
+
factCheck: value.factCheck === true,
|
|
642
|
+
// Say which model an AI draft runs on. The settings slot is
|
|
643
|
+
// root-scoped and receives no session id, so this is the harness
|
|
644
|
+
// default — stating it beats letting the user assume.
|
|
645
|
+
draftModelLabel: value.draftModel === null || value.draftModel === undefined
|
|
646
|
+
? ''
|
|
647
|
+
: t('settings.ai.model', { provider: value.draftModel.provider, model: value.draftModel.model }),
|
|
543
648
|
catalogError: null,
|
|
544
649
|
})
|
|
545
650
|
return state.catalog
|
|
@@ -568,10 +673,14 @@ window.__ModuleLoader__.load({
|
|
|
568
673
|
panel: {
|
|
569
674
|
sessionKey: options.sessionKey,
|
|
570
675
|
phase: 'loading',
|
|
676
|
+
startedAt: Date.now(),
|
|
571
677
|
cardId: options.cardId,
|
|
572
678
|
cardName: options.cardName,
|
|
573
679
|
original: options.original,
|
|
574
680
|
intensity: options.intensity,
|
|
681
|
+
mode: options.mode,
|
|
682
|
+
fidelity: options.fidelity,
|
|
683
|
+
modelCheck: options.modelCheck,
|
|
575
684
|
sendMode: options.sendMode,
|
|
576
685
|
},
|
|
577
686
|
})
|
|
@@ -580,6 +689,9 @@ window.__ModuleLoader__.load({
|
|
|
580
689
|
cardId: options.cardId,
|
|
581
690
|
text: options.original,
|
|
582
691
|
intensity: options.intensity,
|
|
692
|
+
mode: options.mode,
|
|
693
|
+
fidelity: options.fidelity,
|
|
694
|
+
modelCheck: options.modelCheck,
|
|
583
695
|
}).then(
|
|
584
696
|
(value) => {
|
|
585
697
|
const current = state.panel
|
|
@@ -638,6 +750,14 @@ window.__ModuleLoader__.load({
|
|
|
638
750
|
})
|
|
639
751
|
const [cardId, setCardId] = useState(() => readLocal(cardKey(sessionKey)) ?? OFF)
|
|
640
752
|
const [intensity, setIntensity] = useState(() => readLocal(intensityKey(sessionKey)))
|
|
753
|
+
// Per-session overrides for the card's own mode and fidelity. Both are
|
|
754
|
+
// stored as "unset" until the user actually picks something, so following
|
|
755
|
+
// the card stays the default rather than an explicit choice.
|
|
756
|
+
const [modeOverride, setModeOverride] = useState(() => readLocal(modeKey(sessionKey)))
|
|
757
|
+
// Advanced rows start folded: the menu's job is choosing a character, and
|
|
758
|
+
// these overrides are set once and then forgotten.
|
|
759
|
+
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
760
|
+
const [fidelityOverride, setFidelityOverride] = useState(() => readLocal(fidelityKey(sessionKey)))
|
|
641
761
|
|
|
642
762
|
useEffect(() => {
|
|
643
763
|
if (shared.catalog === null && shared.catalogError === null) {
|
|
@@ -651,6 +771,22 @@ window.__ModuleLoader__.load({
|
|
|
651
771
|
const selected = isOff ? null : cards.find((card) => card.id === cardId) ?? null
|
|
652
772
|
const busy = shared.panel !== null && shared.panel.phase === 'loading'
|
|
653
773
|
const effectiveIntensity = intensity ?? selected?.intensity ?? 'medium'
|
|
774
|
+
const effectiveMode = effectiveModeOf(selected, modeOverride)
|
|
775
|
+
const effectiveFidelity = effectiveFidelityOf(selected, fidelityOverride)
|
|
776
|
+
// The model check is a one-time decision about cost, so it is a GLOBAL
|
|
777
|
+
// preference (set in the settings page), not a per-session override.
|
|
778
|
+
// One source of truth: the host config. A client-side copy would be a
|
|
779
|
+
// second setting for the same thing, and the model check is off by
|
|
780
|
+
// default because it is paid for on every send.
|
|
781
|
+
const effectiveModelCheck = shared.factCheck
|
|
782
|
+
|
|
783
|
+
// What the fold would reveal, in one line. A folded-away override the user
|
|
784
|
+
// forgot about is a surprise waiting to happen, so the collapsed row still
|
|
785
|
+
// names anything that is not following the card.
|
|
786
|
+
const advancedSummary = [
|
|
787
|
+
modeOverride === 'llm' ? t('control.mode.llm') : modeOverride === 'template' ? t('control.mode.template') : '',
|
|
788
|
+
fidelityOverride === 'style' ? t('control.fidelity.style') : '',
|
|
789
|
+
].filter((part) => part !== '').join(' · ')
|
|
654
790
|
|
|
655
791
|
// The send-gesture listener is attached once per session but must read
|
|
656
792
|
// CURRENT values. A ref carries them across renders without re-attaching
|
|
@@ -690,6 +826,16 @@ window.__ModuleLoader__.load({
|
|
|
690
826
|
writeLocal(intensityKey(sessionKey), level)
|
|
691
827
|
}, [sessionKey])
|
|
692
828
|
|
|
829
|
+
const setMode = useCallback((mode) => {
|
|
830
|
+
setModeOverride(mode)
|
|
831
|
+
writeLocal(modeKey(sessionKey), mode)
|
|
832
|
+
}, [sessionKey])
|
|
833
|
+
|
|
834
|
+
const setFidelity = useCallback((fidelity) => {
|
|
835
|
+
setFidelityOverride(fidelity)
|
|
836
|
+
writeLocal(fidelityKey(sessionKey), fidelity)
|
|
837
|
+
}, [sessionKey])
|
|
838
|
+
|
|
693
839
|
// Position the menu above the button, clamped to the viewport.
|
|
694
840
|
useEffect(() => {
|
|
695
841
|
if (!open) return
|
|
@@ -745,9 +891,12 @@ window.__ModuleLoader__.load({
|
|
|
745
891
|
cardName: selected.name,
|
|
746
892
|
original: text,
|
|
747
893
|
intensity: effectiveIntensity,
|
|
894
|
+
mode: effectiveMode,
|
|
895
|
+
fidelity: effectiveFidelity,
|
|
896
|
+
modelCheck: effectiveModelCheck,
|
|
748
897
|
sendMode: effectiveSendMode,
|
|
749
898
|
})
|
|
750
|
-
}, [busy, cardId, draft, effectiveIntensity, effectiveSendMode, phase, props.sessionId, selected, sessionKey])
|
|
899
|
+
}, [busy, cardId, draft, effectiveFidelity, effectiveIntensity, effectiveMode, effectiveModelCheck, effectiveSendMode, phase, props.sessionId, selected, sessionKey])
|
|
751
900
|
|
|
752
901
|
// Intercept the composer's OWN send action.
|
|
753
902
|
//
|
|
@@ -883,26 +1032,6 @@ window.__ModuleLoader__.load({
|
|
|
883
1032
|
style: { left: `${menuPos.left}px`, bottom: `${menuPos.bottom}px`, width: `${menuPos.width}px` },
|
|
884
1033
|
},
|
|
885
1034
|
h('div', { className: 'pf-menu-title' }, t('control.menu.title')),
|
|
886
|
-
// "No persona" is a real choice, listed like any card, so the
|
|
887
|
-
// control can always be turned back off.
|
|
888
|
-
h(
|
|
889
|
-
'button',
|
|
890
|
-
{
|
|
891
|
-
type: 'button',
|
|
892
|
-
role: 'menuitemradio',
|
|
893
|
-
'aria-checked': isOff ? 'true' : 'false',
|
|
894
|
-
className: `pf-item${isOff ? ' is-selected' : ''}`,
|
|
895
|
-
onClick: () => pickCard(OFF),
|
|
896
|
-
},
|
|
897
|
-
h('span', { className: 'pf-item-icon', 'aria-hidden': true }, '🚫'),
|
|
898
|
-
h(
|
|
899
|
-
'span',
|
|
900
|
-
{ className: 'pf-item-body' },
|
|
901
|
-
h('span', { className: 'pf-item-name' }, t('control.none')),
|
|
902
|
-
h('span', { className: 'pf-item-desc' }, t('control.none.desc')),
|
|
903
|
-
),
|
|
904
|
-
isOff ? h('span', { className: 'pf-check', 'aria-hidden': true }, '✓') : null,
|
|
905
|
-
),
|
|
906
1035
|
cards.length === 0
|
|
907
1036
|
? h('div', { className: 'pf-hint' }, shared.catalogError ?? t('control.menu.empty'))
|
|
908
1037
|
: cards.map((card) => h(
|
|
@@ -926,6 +1055,28 @@ window.__ModuleLoader__.load({
|
|
|
926
1055
|
),
|
|
927
1056
|
card.id === cardId ? h('span', { className: 'pf-check', 'aria-hidden': true }, '✓') : null,
|
|
928
1057
|
)),
|
|
1058
|
+
// "No persona" is a real choice, listed like any card so the
|
|
1059
|
+
// control can always be turned back off — but LAST: it is an exit
|
|
1060
|
+
// action, and putting it above the cards made it the first thing
|
|
1061
|
+
// a user reads in a menu whose whole point is picking a character.
|
|
1062
|
+
h(
|
|
1063
|
+
'button',
|
|
1064
|
+
{
|
|
1065
|
+
type: 'button',
|
|
1066
|
+
role: 'menuitemradio',
|
|
1067
|
+
'aria-checked': isOff ? 'true' : 'false',
|
|
1068
|
+
className: `pf-item${isOff ? ' is-selected' : ''}`,
|
|
1069
|
+
onClick: () => pickCard(OFF),
|
|
1070
|
+
},
|
|
1071
|
+
h('span', { className: 'pf-item-icon', 'aria-hidden': true }, '🚫'),
|
|
1072
|
+
h(
|
|
1073
|
+
'span',
|
|
1074
|
+
{ className: 'pf-item-body' },
|
|
1075
|
+
h('span', { className: 'pf-item-name' }, t('control.none')),
|
|
1076
|
+
h('span', { className: 'pf-item-desc' }, t('control.none.desc')),
|
|
1077
|
+
),
|
|
1078
|
+
isOff ? h('span', { className: 'pf-check', 'aria-hidden': true }, '✓') : null,
|
|
1079
|
+
),
|
|
929
1080
|
h('div', { className: 'pf-menu-sep' }),
|
|
930
1081
|
h(
|
|
931
1082
|
'div',
|
|
@@ -967,6 +1118,90 @@ window.__ModuleLoader__.load({
|
|
|
967
1118
|
),
|
|
968
1119
|
),
|
|
969
1120
|
h('div', { className: 'pf-hint' }, t(`control.sendMode.hint.${effectiveSendMode}`)),
|
|
1121
|
+
// Advanced settings, folded away.
|
|
1122
|
+
//
|
|
1123
|
+
// Rewrite mode and fidelity are per-session OVERRIDES of a card's
|
|
1124
|
+
// own declaration, so almost everyone leaves them alone. Shown
|
|
1125
|
+
// flat, they gave the menu five equal-weight control rows and made
|
|
1126
|
+
// it read as a settings form instead of a character picker. The
|
|
1127
|
+
// folded row still reports what is overridden, so a choice the
|
|
1128
|
+
// user forgot about cannot hide behind the fold.
|
|
1129
|
+
h(
|
|
1130
|
+
'button',
|
|
1131
|
+
{
|
|
1132
|
+
type: 'button',
|
|
1133
|
+
className: 'pf-item pf-more',
|
|
1134
|
+
'aria-expanded': advancedOpen ? 'true' : 'false',
|
|
1135
|
+
title: t('control.more.open'),
|
|
1136
|
+
onClick: () => setAdvancedOpen((value) => !value),
|
|
1137
|
+
},
|
|
1138
|
+
h('span', { className: 'pf-item-icon', 'aria-hidden': true }, advancedOpen ? '▾' : '▸'),
|
|
1139
|
+
h(
|
|
1140
|
+
'span',
|
|
1141
|
+
{ className: 'pf-item-body' },
|
|
1142
|
+
h('span', { className: 'pf-item-name' }, t('control.more')),
|
|
1143
|
+
advancedSummary !== ''
|
|
1144
|
+
? h('span', { className: 'pf-item-desc' }, `${t('control.more.custom')}${advancedSummary}`)
|
|
1145
|
+
: null,
|
|
1146
|
+
),
|
|
1147
|
+
),
|
|
1148
|
+
advancedOpen
|
|
1149
|
+
? h(
|
|
1150
|
+
'div',
|
|
1151
|
+
{ className: 'pf-more-body' },
|
|
1152
|
+
h(
|
|
1153
|
+
'div',
|
|
1154
|
+
{ className: 'pf-row' },
|
|
1155
|
+
h('span', { className: 'pf-row-label' }, t('control.mode')),
|
|
1156
|
+
h(
|
|
1157
|
+
'span',
|
|
1158
|
+
{ className: 'pf-seg' },
|
|
1159
|
+
[['', t('control.mode.card')], ['llm', t('control.mode.llm')], ['template', t('control.mode.template')]].map(([value, label]) => h(
|
|
1160
|
+
'button',
|
|
1161
|
+
{
|
|
1162
|
+
key: value === '' ? 'card' : value,
|
|
1163
|
+
type: 'button',
|
|
1164
|
+
'aria-pressed': (modeOverride ?? '') === value ? 'true' : 'false',
|
|
1165
|
+
className: (modeOverride ?? '') === value ? 'is-on' : '',
|
|
1166
|
+
onClick: () => setMode(value === '' ? undefined : value),
|
|
1167
|
+
},
|
|
1168
|
+
label,
|
|
1169
|
+
)),
|
|
1170
|
+
),
|
|
1171
|
+
),
|
|
1172
|
+
h('div', { className: 'pf-hint' }, t('control.mode.hint')),
|
|
1173
|
+
// Only a card that may add behavioural constraints has
|
|
1174
|
+
// anything to narrow. On a voice-only card both options
|
|
1175
|
+
// resolve to the same thing, and offering that choice --
|
|
1176
|
+
// with a paragraph explaining why the other one is refused --
|
|
1177
|
+
// is a control that cannot do anything.
|
|
1178
|
+
selected?.fidelity === 'strategy'
|
|
1179
|
+
? [
|
|
1180
|
+
h(
|
|
1181
|
+
'div',
|
|
1182
|
+
{ className: 'pf-row' },
|
|
1183
|
+
h('span', { className: 'pf-row-label' }, t('control.fidelity')),
|
|
1184
|
+
h(
|
|
1185
|
+
'span',
|
|
1186
|
+
{ className: 'pf-seg' },
|
|
1187
|
+
[['', t('control.fidelity.card')], ['style', t('control.fidelity.style')]].map(([value, label]) => h(
|
|
1188
|
+
'button',
|
|
1189
|
+
{
|
|
1190
|
+
key: value === '' ? 'card' : value,
|
|
1191
|
+
type: 'button',
|
|
1192
|
+
'aria-pressed': (fidelityOverride ?? '') === value ? 'true' : 'false',
|
|
1193
|
+
className: (fidelityOverride ?? '') === value ? 'is-on' : '',
|
|
1194
|
+
onClick: () => setFidelity(value === '' ? undefined : value),
|
|
1195
|
+
},
|
|
1196
|
+
label,
|
|
1197
|
+
)),
|
|
1198
|
+
),
|
|
1199
|
+
),
|
|
1200
|
+
h('div', { className: 'pf-hint' }, t('control.fidelity.hint.strategy')),
|
|
1201
|
+
]
|
|
1202
|
+
: null,
|
|
1203
|
+
)
|
|
1204
|
+
: null,
|
|
970
1205
|
h('div', { className: 'pf-menu-sep' }),
|
|
971
1206
|
h(
|
|
972
1207
|
'button',
|
|
@@ -999,6 +1234,23 @@ window.__ModuleLoader__.load({
|
|
|
999
1234
|
const panel = shared.panel
|
|
1000
1235
|
const inputActions = props.inputActions
|
|
1001
1236
|
const setDraft = typeof inputActions?.setDraft === 'function' ? inputActions.setDraft : null
|
|
1237
|
+
|
|
1238
|
+
// Seconds since the in-flight rewrite started, ticking once a second.
|
|
1239
|
+
//
|
|
1240
|
+
// Without this the panel shows "改写中" with no sense of progress, so a
|
|
1241
|
+
// rewrite waiting on a slow model reads as FROZEN — the user cannot tell
|
|
1242
|
+
// "working" from "hung", and reports it as stuck. A live counter answers
|
|
1243
|
+
// that for nothing, and makes the host's own timeout (120s) legible
|
|
1244
|
+
// instead of arbitrary.
|
|
1245
|
+
const [tick, setTick] = useState(0)
|
|
1246
|
+
useEffect(() => {
|
|
1247
|
+
if (panel === null || panel.phase !== 'loading') return
|
|
1248
|
+
const started = typeof panel.startedAt === 'number' ? panel.startedAt : Date.now()
|
|
1249
|
+
setTick(Math.round((Date.now() - started) / 1000))
|
|
1250
|
+
const timer = window.setInterval(() => setTick(Math.round((Date.now() - started) / 1000)), 1000)
|
|
1251
|
+
return () => window.clearInterval(timer)
|
|
1252
|
+
}, [panel === null, panel?.phase, panel?.startedAt])
|
|
1253
|
+
const elapsedSeconds = panel !== null && panel.phase === 'loading' ? Math.max(0, tick) : null
|
|
1002
1254
|
const submit = typeof inputActions?.submit === 'function' ? inputActions.submit : null
|
|
1003
1255
|
const owned = panel !== null && panel.sessionKey === sessionKey ? panel : null
|
|
1004
1256
|
// Guards the direct-send effect against firing twice for one result.
|
|
@@ -1031,6 +1283,12 @@ window.__ModuleLoader__.load({
|
|
|
1031
1283
|
/** Replace the composer content, then optionally submit it. */
|
|
1032
1284
|
const apply = (text, thenSubmit) => {
|
|
1033
1285
|
if (setDraft === null) return
|
|
1286
|
+
// Putting the ORIGINAL back has to forget that a rewrite ever happened.
|
|
1287
|
+
// Without this, `lastRewrite` still records `original: A`, so the next
|
|
1288
|
+
// send on the restored draft A matches "already rewritten" and the
|
|
1289
|
+
// plugin silently does nothing — a rewrite you cancelled would be
|
|
1290
|
+
// impossible to retry. Restoring is a rewind, not a reviewed result.
|
|
1291
|
+
if (text === panel.original) lastRewrite.delete(sessionKey)
|
|
1034
1292
|
setDraft(text)
|
|
1035
1293
|
if (thenSubmit !== true || submit === null) {
|
|
1036
1294
|
closePanel()
|
|
@@ -1062,6 +1320,7 @@ window.__ModuleLoader__.load({
|
|
|
1062
1320
|
)
|
|
1063
1321
|
|
|
1064
1322
|
if (panel.phase === 'loading') {
|
|
1323
|
+
const seconds = elapsedSeconds ?? 0
|
|
1065
1324
|
return h(
|
|
1066
1325
|
'div',
|
|
1067
1326
|
{ className: 'pf-panel' },
|
|
@@ -1069,7 +1328,17 @@ window.__ModuleLoader__.load({
|
|
|
1069
1328
|
h(
|
|
1070
1329
|
'div',
|
|
1071
1330
|
{ className: 'pf-panel-body' },
|
|
1072
|
-
h(
|
|
1331
|
+
h(
|
|
1332
|
+
'div',
|
|
1333
|
+
{ className: 'pf-meta' },
|
|
1334
|
+
t('panel.loading', { name: panel.cardName ?? panel.cardId }),
|
|
1335
|
+
h('span', { className: 'pf-elapsed', 'aria-live': 'off' }, `${seconds}s`),
|
|
1336
|
+
),
|
|
1337
|
+
// Past half the host's own timeout, say so instead of leaving the
|
|
1338
|
+
// user to wonder whether anything is still happening.
|
|
1339
|
+
seconds >= 45
|
|
1340
|
+
? h('div', { className: 'pf-note is-muted', style: { marginTop: '8px' } }, t('panel.slow', { seconds }))
|
|
1341
|
+
: null,
|
|
1073
1342
|
),
|
|
1074
1343
|
)
|
|
1075
1344
|
}
|
|
@@ -1100,18 +1369,33 @@ window.__ModuleLoader__.load({
|
|
|
1100
1369
|
const text = typeof result.text === 'string' ? result.text : ''
|
|
1101
1370
|
const isTemplate = result.mode === 'template'
|
|
1102
1371
|
const check = result.factCheck ?? { state: 'skipped' }
|
|
1372
|
+
const facts = result.facts ?? { state: 'ok', missing: [], added: [] }
|
|
1103
1373
|
const stale = typeof draft === 'string' && draft !== panel.original
|
|
1104
1374
|
const direct = panel.sendMode === 'direct'
|
|
1105
1375
|
const intensityLabel = t(`control.intensity.${result.intensity ?? panel.intensity ?? 'medium'}`)
|
|
1106
1376
|
|
|
1107
1377
|
const notes = []
|
|
1108
1378
|
if (stale && !direct) notes.push(h('div', { key: 'stale', className: 'pf-note is-muted' }, t('panel.stale')))
|
|
1379
|
+
// The deterministic verdict comes first: it is always available, it costs
|
|
1380
|
+
// nothing, and it names the exact token that went missing — which is more
|
|
1381
|
+
// actionable than a model's sentence about it.
|
|
1382
|
+
if (facts.state === 'drift') {
|
|
1383
|
+
const parts = []
|
|
1384
|
+
if (facts.missing.length > 0) parts.push(`${t('panel.factsMissing')}${facts.missing.join('、')}`)
|
|
1385
|
+
if (facts.added.length > 0) parts.push(`${t('panel.factsAdded')}${facts.added.join('、')}`)
|
|
1386
|
+
notes.push(h('div', { key: 'facts', className: 'pf-note is-warn' }, `${t('panel.factsDrift')} — ${parts.join(';')}`))
|
|
1387
|
+
}
|
|
1109
1388
|
if (check.state === 'drift') {
|
|
1110
1389
|
notes.push(h('div', { key: 'drift', className: 'pf-note is-warn' }, `${t('panel.drift')}${check.reason !== undefined ? `:${check.reason}` : ''}`))
|
|
1111
1390
|
} else if (check.state === 'unavailable') {
|
|
1112
1391
|
notes.push(h('div', { key: 'unavail', className: 'pf-note is-muted' }, `${t('panel.checkUnavailable')}${check.reason !== undefined ? `:${check.reason}` : ''}`))
|
|
1113
1392
|
} else if (check.state === 'ok') {
|
|
1114
1393
|
notes.push(h('div', { key: 'ok', className: 'pf-note is-ok' }, t('panel.checkOk')))
|
|
1394
|
+
} else if (check.state === 'skipped' && facts.state === 'ok') {
|
|
1395
|
+
// With the model check off, the free check still reported. Say so, and
|
|
1396
|
+
// say plainly what it did NOT cover — a clean verdict here is narrower
|
|
1397
|
+
// than a clean verdict with the model check on.
|
|
1398
|
+
notes.push(h('div', { key: 'factsonly', className: 'pf-note is-muted' }, t('panel.factsOnly')))
|
|
1115
1399
|
}
|
|
1116
1400
|
if (direct) notes.push(h('div', { key: 'sent', className: 'pf-note is-muted' }, `${t('panel.sent')} — ${t('panel.sent.hint')}`))
|
|
1117
1401
|
|
|
@@ -1171,6 +1455,9 @@ window.__ModuleLoader__.load({
|
|
|
1171
1455
|
cardName: panel.cardName,
|
|
1172
1456
|
original: panel.original,
|
|
1173
1457
|
intensity: panel.intensity,
|
|
1458
|
+
mode: panel.mode,
|
|
1459
|
+
fidelity: panel.fidelity,
|
|
1460
|
+
modelCheck: panel.modelCheck,
|
|
1174
1461
|
sendMode: panel.sendMode,
|
|
1175
1462
|
}),
|
|
1176
1463
|
},
|
|
@@ -1196,6 +1483,7 @@ window.__ModuleLoader__.load({
|
|
|
1196
1483
|
style: '',
|
|
1197
1484
|
template: '',
|
|
1198
1485
|
examplesText: '',
|
|
1486
|
+
byIntensityText: { light: '', medium: '', strong: '', zealot: '' },
|
|
1199
1487
|
}
|
|
1200
1488
|
|
|
1201
1489
|
/** Render a card into the form's flat shape. */
|
|
@@ -1211,6 +1499,16 @@ window.__ModuleLoader__.load({
|
|
|
1211
1499
|
style: card.style ?? '',
|
|
1212
1500
|
template: card.template ?? '',
|
|
1213
1501
|
examplesText: (card.examples ?? []).map((example) => `${example.from} => ${example.to}`).join('\n'),
|
|
1502
|
+
// The per-intensity demonstrations are part of the card, so the form
|
|
1503
|
+
// carries them. Leaving them out of the form dropped them on the next
|
|
1504
|
+
// save: editing a bundled card's name silently discarded all four
|
|
1505
|
+
// intensity example sets, and the card fell back to the flat list.
|
|
1506
|
+
byIntensityText: Object.fromEntries(
|
|
1507
|
+
INTENSITY_LEVELS.map((level) => [
|
|
1508
|
+
level,
|
|
1509
|
+
(card.examplesByIntensity?.[level] ?? []).map((example) => `${example.from} => ${example.to}`).join('\n'),
|
|
1510
|
+
]),
|
|
1511
|
+
),
|
|
1214
1512
|
}
|
|
1215
1513
|
}
|
|
1216
1514
|
|
|
@@ -1230,7 +1528,7 @@ window.__ModuleLoader__.load({
|
|
|
1230
1528
|
return out
|
|
1231
1529
|
}
|
|
1232
1530
|
|
|
1233
|
-
function CardsSettings() {
|
|
1531
|
+
function CardsSettings(props) {
|
|
1234
1532
|
const shared = useSyncExternalStore(subscribe, getState)
|
|
1235
1533
|
const [form, setForm] = useState(null)
|
|
1236
1534
|
const [status, setStatus] = useState(null)
|
|
@@ -1238,6 +1536,13 @@ window.__ModuleLoader__.load({
|
|
|
1238
1536
|
// Nothing here runs on its own: the drift check is an explicit action so
|
|
1239
1537
|
// the list stays clean, and only a drifted card grows a restore button.
|
|
1240
1538
|
const [drift, setDrift] = useState(null)
|
|
1539
|
+
// AI 代填: a rough idea in, a prefilled form out.
|
|
1540
|
+
const [brief, setBrief] = useState('')
|
|
1541
|
+
const [drafting, setDrafting] = useState(false)
|
|
1542
|
+
// The AI box belongs to the NEW-card flow, not to the page: showing it
|
|
1543
|
+
// permanently put two unrelated "make a card" actions side by side and
|
|
1544
|
+
// left the user guessing which one to press. It opens with 新建角色卡.
|
|
1545
|
+
const [aiOpen, setAiOpen] = useState(false)
|
|
1241
1546
|
|
|
1242
1547
|
useEffect(() => {
|
|
1243
1548
|
void loadCatalog(false).catch(() => {})
|
|
@@ -1260,6 +1565,15 @@ window.__ModuleLoader__.load({
|
|
|
1260
1565
|
if (form.icon.trim() !== '') payload.icon = form.icon.trim()
|
|
1261
1566
|
if (form.description.trim() !== '') payload.description = form.description.trim()
|
|
1262
1567
|
if (form.mode === 'template') payload.template = form.template
|
|
1568
|
+
// Only rungs the user actually filled are sent. An empty textarea means
|
|
1569
|
+
// "no per-intensity set for this level", which makes `examplesFor` fall
|
|
1570
|
+
// back to the flat list — the documented behaviour, not an empty demo.
|
|
1571
|
+
const byIntensity = {}
|
|
1572
|
+
for (const level of INTENSITY_LEVELS) {
|
|
1573
|
+
const parsed = parseExamples(form.byIntensityText?.[level])
|
|
1574
|
+
if (parsed.length > 0) byIntensity[level] = parsed
|
|
1575
|
+
}
|
|
1576
|
+
if (Object.keys(byIntensity).length > 0) payload.examplesByIntensity = byIntensity
|
|
1263
1577
|
saveCard(payload).then(
|
|
1264
1578
|
() => {
|
|
1265
1579
|
setStatus({ kind: 'ok', text: t('settings.saved') })
|
|
@@ -1271,7 +1585,11 @@ window.__ModuleLoader__.load({
|
|
|
1271
1585
|
}
|
|
1272
1586
|
|
|
1273
1587
|
const remove = (card) => {
|
|
1274
|
-
|
|
1588
|
+
// Deleting an edited card is not the same act as deleting a card the
|
|
1589
|
+
// user wrote from scratch: it falls back to the bundled version rather
|
|
1590
|
+
// than removing the character entirely, and the wording has to say so.
|
|
1591
|
+
const confirmKey = card.origin === 'edited' ? 'settings.deleteEditedConfirm' : 'settings.deleteConfirm'
|
|
1592
|
+
if (!window.confirm(t(confirmKey))) return
|
|
1275
1593
|
deleteCard(card.id).then(
|
|
1276
1594
|
() => {
|
|
1277
1595
|
setStatus({ kind: 'ok', text: t('settings.saved') })
|
|
@@ -1297,6 +1615,59 @@ window.__ModuleLoader__.load({
|
|
|
1297
1615
|
)
|
|
1298
1616
|
}
|
|
1299
1617
|
|
|
1618
|
+
/**
|
|
1619
|
+
* Ask the host to write a card from the user's rough idea, then drop the
|
|
1620
|
+
* result into the form.
|
|
1621
|
+
*
|
|
1622
|
+
* The generated card is NEVER saved here. It only prefills the form, so
|
|
1623
|
+
* the user reviews the wording, the examples and the id before anything
|
|
1624
|
+
* reaches the card directory — the same standard the rewrite panel is
|
|
1625
|
+
* held to. A generation the user dislikes costs one reload.
|
|
1626
|
+
*/
|
|
1627
|
+
const generate = () => {
|
|
1628
|
+
const text = brief.trim()
|
|
1629
|
+
if (text === '') {
|
|
1630
|
+
setStatus({ kind: 'error', text: t('settings.ai.needBrief') })
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
setDrafting(true)
|
|
1634
|
+
setStatus(null)
|
|
1635
|
+
draftCard({ brief: text, sessionId: typeof props?.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : undefined }).then(
|
|
1636
|
+
(value) => {
|
|
1637
|
+
setDrafting(false)
|
|
1638
|
+
setForm(toForm(value?.card ?? {}))
|
|
1639
|
+
setStatus({ kind: 'ok', text: t('settings.ai.hint') })
|
|
1640
|
+
},
|
|
1641
|
+
(error) => {
|
|
1642
|
+
setDrafting(false)
|
|
1643
|
+
setStatus({ kind: 'error', text: t('settings.ai.failed', { message: error?.message ?? t('error.internal') }) })
|
|
1644
|
+
},
|
|
1645
|
+
)
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Start a new card: open a blank form AND offer the AI box beside it.
|
|
1650
|
+
*
|
|
1651
|
+
* Both live in the same flow on purpose. A blank form and an AI box
|
|
1652
|
+
* presented as two equal toolbar buttons made the user choose between
|
|
1653
|
+
* them before knowing they were related; opening them together makes
|
|
1654
|
+
* "write it yourself, or describe it and let the AI start you off" one
|
|
1655
|
+
* decision instead of two.
|
|
1656
|
+
*/
|
|
1657
|
+
const startNew = () => {
|
|
1658
|
+
setForm({ ...BLANK_CARD })
|
|
1659
|
+
setBrief('')
|
|
1660
|
+
setAiOpen(true)
|
|
1661
|
+
setStatus(null)
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Close the new-card flow entirely (form and AI box together). */
|
|
1665
|
+
const cancelForm = () => {
|
|
1666
|
+
setForm(null)
|
|
1667
|
+
setAiOpen(false)
|
|
1668
|
+
setBrief('')
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1300
1671
|
/** Ask the host which user cards differ from their bundled counterpart. */
|
|
1301
1672
|
const check = () => {
|
|
1302
1673
|
setDrift(undefined)
|
|
@@ -1321,7 +1692,7 @@ window.__ModuleLoader__.load({
|
|
|
1321
1692
|
h(
|
|
1322
1693
|
'div',
|
|
1323
1694
|
{ className: 'pf-settings-bar' },
|
|
1324
|
-
h('button', { type: 'button', className: 'pf-action', onClick:
|
|
1695
|
+
h('button', { type: 'button', className: 'pf-action', onClick: startNew }, t('settings.new')),
|
|
1325
1696
|
h(
|
|
1326
1697
|
'button',
|
|
1327
1698
|
{
|
|
@@ -1348,9 +1719,15 @@ window.__ModuleLoader__.load({
|
|
|
1348
1719
|
),
|
|
1349
1720
|
h('span', { className: 'pf-small' }, t('settings.count', { n: cards.length })),
|
|
1350
1721
|
),
|
|
1351
|
-
// The
|
|
1722
|
+
// Discoverability, not a second action. The AI box lives inside the
|
|
1723
|
+
// new-card flow; this line is what tells a user it exists, so they do
|
|
1724
|
+
// not have to press a button to find out.
|
|
1725
|
+
h('div', { className: 'pf-small', style: { margin: '0 0 10px' } }, t('settings.ai.tip')),
|
|
1726
|
+
// The check RESULT is real feedback. The instruction line is not
|
|
1727
|
+
// rendered: the button's tooltip already explains the action, and
|
|
1728
|
+
// repeating it on the page is the same text twice.
|
|
1352
1729
|
drift === null
|
|
1353
|
-
?
|
|
1730
|
+
? null
|
|
1354
1731
|
: drift === undefined
|
|
1355
1732
|
? h('div', { className: 'pf-note is-muted', style: { marginBottom: '10px' } }, t('settings.check.busy'))
|
|
1356
1733
|
: drift.length === 0
|
|
@@ -1381,6 +1758,49 @@ window.__ModuleLoader__.load({
|
|
|
1381
1758
|
? h(
|
|
1382
1759
|
'div',
|
|
1383
1760
|
{ className: 'pf-form', style: { margin: '12px 0' } },
|
|
1761
|
+
// The AI box, first thing in the new-card flow: describe it, or
|
|
1762
|
+
// fill the fields below by hand. It is collapsible so a user who
|
|
1763
|
+
// came to type does not have to scroll past it.
|
|
1764
|
+
h(
|
|
1765
|
+
'div',
|
|
1766
|
+
{ className: 'pf-ai' },
|
|
1767
|
+
h(
|
|
1768
|
+
'button',
|
|
1769
|
+
{
|
|
1770
|
+
type: 'button',
|
|
1771
|
+
className: 'pf-ai-head',
|
|
1772
|
+
'aria-expanded': aiOpen ? 'true' : 'false',
|
|
1773
|
+
onClick: () => setAiOpen((value) => !value),
|
|
1774
|
+
},
|
|
1775
|
+
h('span', { 'aria-hidden': true }, aiOpen ? '▾' : '▸'),
|
|
1776
|
+
h('span', null, t('settings.ai')),
|
|
1777
|
+
),
|
|
1778
|
+
aiOpen
|
|
1779
|
+
? h(
|
|
1780
|
+
'div',
|
|
1781
|
+
{ style: { display: 'grid', gap: '8px', padding: '8px 10px 10px' } },
|
|
1782
|
+
h('textarea', {
|
|
1783
|
+
value: brief,
|
|
1784
|
+
placeholder: t('settings.ai.briefPlaceholder'),
|
|
1785
|
+
disabled: drafting,
|
|
1786
|
+
onChange: (event) => setBrief(event.target.value),
|
|
1787
|
+
style: { width: '100%', boxSizing: 'border-box', minHeight: '60px', padding: '6px 8px', border: '.5px solid var(--dsw-alias-border-l3)', borderRadius: 'var(--dsw-radius-sm)', background: 'var(--dsw-alias-bg-base)', color: 'var(--dsw-alias-label-primary)', font: 'inherit', fontSize: '12px', lineHeight: '18px', outline: 'none', resize: 'vertical' },
|
|
1788
|
+
}),
|
|
1789
|
+
h('div', { className: 'pf-small' }, t('settings.ai.desc')),
|
|
1790
|
+
h(
|
|
1791
|
+
'div',
|
|
1792
|
+
{ className: 'pf-actions' },
|
|
1793
|
+
h('button', {
|
|
1794
|
+
type: 'button',
|
|
1795
|
+
className: 'pf-action',
|
|
1796
|
+
disabled: drafting || brief.trim() === '',
|
|
1797
|
+
onClick: generate,
|
|
1798
|
+
}, drafting ? t('settings.ai.busy') : t('settings.ai.generate')),
|
|
1799
|
+
h('span', { className: 'pf-small' }, shared.draftModelLabel),
|
|
1800
|
+
),
|
|
1801
|
+
)
|
|
1802
|
+
: null,
|
|
1803
|
+
),
|
|
1384
1804
|
h(
|
|
1385
1805
|
'div',
|
|
1386
1806
|
{ className: 'pf-grid2' },
|
|
@@ -1453,11 +1873,33 @@ window.__ModuleLoader__.load({
|
|
|
1453
1873
|
h('div', { className: 'pf-small' }, t('settings.hint.examples')),
|
|
1454
1874
|
)
|
|
1455
1875
|
: null,
|
|
1876
|
+
// The four intensity sets. These are part of the card, so the form
|
|
1877
|
+
// has to show them: an editable card whose strongest feature is
|
|
1878
|
+
// invisible is a card the user cannot trust an edit to preserve.
|
|
1879
|
+
form.mode === 'llm'
|
|
1880
|
+
? h(
|
|
1881
|
+
'div',
|
|
1882
|
+
{ className: 'pf-field' },
|
|
1883
|
+
h('label', null, t('settings.field.byIntensity')),
|
|
1884
|
+
...INTENSITY_LEVELS.map((level) => h(
|
|
1885
|
+
'div',
|
|
1886
|
+
{ key: level, style: { display: 'grid', gap: '2px', marginBottom: '6px' } },
|
|
1887
|
+
h('div', { className: 'pf-small' }, t(`control.intensity.${level}`)),
|
|
1888
|
+
h('textarea', {
|
|
1889
|
+
value: form.byIntensityText?.[level] ?? '',
|
|
1890
|
+
placeholder: t('settings.field.byIntensity.empty'),
|
|
1891
|
+
onChange: (event) => set({ byIntensityText: { ...(form.byIntensityText ?? {}), [level]: event.target.value } }),
|
|
1892
|
+
style: { minHeight: '60px' },
|
|
1893
|
+
}),
|
|
1894
|
+
)),
|
|
1895
|
+
h('div', { className: 'pf-small' }, t('settings.hint.byIntensity')),
|
|
1896
|
+
)
|
|
1897
|
+
: null,
|
|
1456
1898
|
h(
|
|
1457
1899
|
'div',
|
|
1458
1900
|
{ className: 'pf-actions' },
|
|
1459
1901
|
h('button', { type: 'button', className: 'pf-action is-primary', onClick: commit, disabled: form.id.trim() === '' || form.name.trim() === '' }, t('settings.save')),
|
|
1460
|
-
h('button', { type: 'button', className: 'pf-action', onClick:
|
|
1902
|
+
h('button', { type: 'button', className: 'pf-action', onClick: cancelForm }, t('settings.cancel')),
|
|
1461
1903
|
),
|
|
1462
1904
|
)
|
|
1463
1905
|
: null,
|
|
@@ -1475,7 +1917,7 @@ window.__ModuleLoader__.load({
|
|
|
1475
1917
|
'span',
|
|
1476
1918
|
{ className: 'pf-card-name' },
|
|
1477
1919
|
card.name,
|
|
1478
|
-
h('span', { className: 'pf-tag' },
|
|
1920
|
+
h('span', { className: 'pf-tag' }, t(`settings.origin.${card.origin ?? 'custom'}`)),
|
|
1479
1921
|
h('span', { className: 'pf-tag' }, card.mode === 'template' ? 'template' : 'llm'),
|
|
1480
1922
|
h('span', { className: 'pf-tag' }, t(`control.intensity.${card.intensity}`)),
|
|
1481
1923
|
),
|
|
@@ -1491,7 +1933,13 @@ window.__ModuleLoader__.load({
|
|
|
1491
1933
|
onClick: () => reset(card),
|
|
1492
1934
|
}, t('settings.reset'))
|
|
1493
1935
|
: null,
|
|
1494
|
-
|
|
1936
|
+
// Delete is offered only where it can actually take effect. A card
|
|
1937
|
+
// whose content still matches the bundle has no user-owned file:
|
|
1938
|
+
// removing it just clears a copied seed that the next start writes
|
|
1939
|
+
// back, so the button would report success and change nothing.
|
|
1940
|
+
card.origin === 'builtin'
|
|
1941
|
+
? null
|
|
1942
|
+
: h('button', { type: 'button', className: 'pf-action', onClick: () => remove(card) }, t('settings.delete')),
|
|
1495
1943
|
)),
|
|
1496
1944
|
),
|
|
1497
1945
|
)
|