dsh-persona-forge 0.3.0 → 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 CHANGED
@@ -46,12 +46,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
46
46
 
47
47
  ### Changed
48
48
 
49
+ - **The composer menu is a picker again, not a settings form.** It had grown to
50
+ five equal-weight segmented rows plus 248 characters of permanently visible
51
+ explanation, so picking a character meant scanning past four configuration
52
+ rows every time. It is now two layers: card list, intensity and send mode
53
+ always visible; **rewrite mode and fidelity** — per-session overrides of a
54
+ card's own declaration — folded behind "More settings", shut by default. The
55
+ collapsed row still names anything overridden, so a hidden choice cannot
56
+ surprise the user later. Measured: 5 rows + 248 hint chars → 2 rows + 0
57
+ permanent hint chars.
58
+
59
+ - **Fidelity is only offered when the card may add behavioural constraints.** On
60
+ a voice-only card both options resolve identically, so the row was a control
61
+ that could not do anything, paired with a paragraph explaining why the thing
62
+ you would want was refused. It is now absent on `style` cards.
63
+
64
+ - **The model check moved to the settings page** as a global preference rather
65
+ than a per-session switch in the composer menu. It answers "do I want to pay
66
+ for the second call", which is decided once, not re-asked on the way to every
67
+ send.
68
+
69
+ - "No persona" moved to the end of the card list: it is an exit action, and
70
+ listing it first made it the first thing read in a menu whose point is choosing
71
+ a character.
72
+
49
73
  - **The model-based preservation check is now off by default** and switchable
50
- from the menu. It is the only thing that can see a requirement reworded weaker,
51
- or a constraint added in prose — but it costs a second model call on **every**
52
- send, and the new local check already proves the common and most damaging
53
- failure for free. `factCheck` in the plugin config now sets that default, and
54
- only an explicit `true` enables it.
74
+ from the settings page. It is the only thing that can see a requirement
75
+ reworded weaker, or a constraint added in prose — but it costs a second model
76
+ call on **every** send, and the new local check already proves the common and
77
+ most damaging failure for free. `factCheck` in the plugin config now sets that
78
+ default, and only an explicit `true` enables it.
55
79
 
56
80
  - The Chinese README is the primary `README.md` and the English one is
57
81
  `README.en.md`, so GitHub, npm and the repository landing page open in Chinese,
@@ -82,6 +106,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
82
106
  confirmation for an edited bundled card says that its changes are discarded
83
107
  and the bundled version returns.
84
108
 
109
+ - **A cancelled rewrite could not be retried.** `lastRewrite` was written on
110
+ every successful rewrite and never removed, so that the send gesture could
111
+ tell "the rewrite is already in the composer" from "not rewritten yet".
112
+ Restoring the original put the draft text back but left that memory in place,
113
+ so the next send matched "already rewritten" and the plugin silently let the
114
+ raw text through. Restoring is a rewind, not a reviewed result, so it now
115
+ deletes the entry — conditionally on putting back the original, because
116
+ sending the rewrite itself must still count as already rewritten.
117
+
85
118
  - **A narrowed fidelity never reached the model.** The route computed the
86
119
  narrowed value and reported it in the response, but `buildSystemPrompt` read
87
120
  `card.fidelity` directly — so the setting appeared to work and did nothing. The
package/README.en.md CHANGED
@@ -126,22 +126,15 @@ you want them gone.
126
126
 
127
127
  ## Use
128
128
 
129
- 1. Click 🎭 in the composer tool row and pick a character. **No persona** in the
130
- same menu turns rewriting off — that is the state the control starts in, and
131
- your choice is remembered per session.
132
- 2. Set **intensity, rewrite mode, fidelity, model check** and **send mode**:
129
+ 1. Click 🎭 in the composer tool row and pick a character. **No persona**, at the
130
+ end of the same list, turns rewriting off — that is the state the control
131
+ starts in, and your choice is remembered per session.
132
+ 2. Set **intensity** and **send mode**:
133
133
  - Intensity is a four-rung ladder: **Light · Medium · Strong · Zealot**
134
134
  (轻 · 中 · 重 · 狂热). Light adds a touch of flavour; Medium is clear but not
135
135
  dominant; Strong gives the voice real weight while keeping the request the
136
136
  first thing the reader sees; Zealot is ritual throughout. A card carries
137
137
  its own default, and this choice overrides it for your session.
138
- - **Rewrite mode**: Card default / Model rewrite / Template. A temporary
139
- switch applies to this session only; if the card has no content for the
140
- mode you picked (no `style`, or no `template`), the override is ignored and
141
- falls back to the card's own mode.
142
- - **Fidelity**: Card default / Voice only. **Narrowing only, never widening**
143
- — see below.
144
- - **Model check**: on / off — see *The preservation check* below.
145
138
  - **Review first** — the rewrite appears in the comparison panel below the
146
139
  composer; you read it and then press Send there. Your draft is untouched
147
140
  until you confirm.
@@ -151,6 +144,12 @@ you want them gone.
151
144
  3. With a character selected, the composer's normal **Send** button — or
152
145
  **Enter** — *is* the rewrite trigger.
153
146
 
147
+ **More settings**, at the bottom of the menu, is folded shut by default. It holds
148
+ **rewrite mode** and **fidelity** — per-session OVERRIDES of a card's own
149
+ declaration that most people set once and never touch, so they do not take space
150
+ in a menu whose job is choosing a character. When something *is* overridden, the
151
+ collapsed row names it, so a choice you forgot about cannot hide behind the fold.
152
+
154
153
  The rewrite runs **once per draft**: after a rewrite lands, sending goes through
155
154
  as-is instead of rewriting the output again. Editing the draft re-arms it.
156
155
 
@@ -214,10 +213,11 @@ is already proven for free by layer 1, so paying a model call on every send to
214
213
  re-check it is a waste. Turn it on when the drafts are technical enough to
215
214
  warrant it.
216
215
 
217
- The 🎭 menu has **Model check: on / off**, remembered per session. With it off
218
- the panel states plainly that the local check ran and spent no tokens, and says
219
- what that check does **not** cover — a narrow verdict has to declare its own
220
- limits.
216
+ The switch lives in **Settings → Persona** ("Enable the model check") and is a
217
+ global preference: it answers "do I want to pay for the second call", which is
218
+ decided once rather than re-asked on the way to every send. With it off the panel
219
+ states plainly that the local check ran and spent no tokens, and says what that
220
+ check does **not** cover — a narrow verdict has to declare its own limits.
221
221
 
222
222
  Both layers are **advisory** and never block a rewrite: they annotate, not stop.
223
223
 
@@ -384,9 +384,12 @@ ever narrowed, never widened:**
384
384
  author's decision, not something to grant with a click on the way to Send. To
385
385
  widen, edit the card.
386
386
 
387
- The 🎭 menu's *Fidelity* defaults to **Card default**; on a `style` card the hint
388
- says it cannot be widened here. The server enforces the same rule, so the UI
389
- never offers a control it would ignore.
387
+ The control appears under *More settings*, and **only when the card is
388
+ `strategy`**. On a `style` card it is not rendered at all: both options would
389
+ resolve identically there, and showing a control that cannot do anything — plus a
390
+ paragraph explaining why the other one is refused — is worse than showing
391
+ nothing. The server enforces the same rule, so the UI never offers a control it
392
+ would ignore.
390
393
 
391
394
  ### `mode: template`
392
395
 
package/README.md CHANGED
@@ -108,19 +108,18 @@ dsh plugin --profile web remove dsh-persona-forge
108
108
 
109
109
  ## 使用
110
110
 
111
- 1. 点输入框工具行的 🎭,选一个角色。同一菜单里的 **「无角色」** 可关闭改写 —— 这也是控件的初始状态,选择按会话记住。
112
- 2. 设置**强度、改写方式、保真度、模型校验、发送方式**:
111
+ 1. 点输入框工具行的 🎭,选一个角色。列表最后的 **「无角色」** 可关闭改写 —— 这也是控件的初始状态,选择按会话记住。
112
+ 2. 设置**强度和发送方式**:
113
113
  - 强度是四档阶梯:**轻 · 中 · 重 · 狂热**(Light · Medium · Strong · Zealot)。轻=只加一点味道;中=明显但不喧宾夺主;重=语气充分,需求依然一眼可见;狂热=通篇仪式化。每张卡有自己的默认值,这里的选择会覆盖它。
114
- - **改写方式**:跟随卡片(默认)/ 模型改写 / 模板套用。临时切换只影响当前会话;如果卡片没有对应的内容(没有 `style` 或没有 `template`),这个覆盖会被忽略并回退到卡片自己的方式。
115
- - **保真度**:跟随卡片(默认)/ 只改语气。**只能收窄,不能放宽** —— 见下方说明。
116
- - **模型校验**:开/关,见下方「技术事实校验」。
117
114
  - **先审查** —— 改写结果显示在输入框下方的对比面板里;你看过之后按面板里的发送。**在确认之前,你的草稿一字不动。**
118
115
  - **直接发出** —— 改写完成后立即作为你的消息发出。面板会保留显示实际发出的内容,因为已发出的消息无法撤回。
119
116
  3. 选了角色之后,输入框**原生的发送按钮、以及 Enter 键**就是改写触发器。
120
117
 
118
+ 菜单底部的 **「更多设置」** 默认收起,里面是**改写方式**与**保真度** —— 它们是对卡片自身声明的**按会话覆盖**,多数人设一次就不会再动,所以不占主菜单。收起时若有覆盖,这一行会**写出改了什么**,不会让一个你忘了的选择藏在折叠后面。
119
+
121
120
  **同一份草稿只改写一次**:改写结果落到输入框后,再按发送会原样发出,不会把改写结果再改写一遍。改动草稿会重新触发改写。
122
121
 
123
- 你的草稿会被保留:**还原原文** 可恢复,**仅回填** 只把改写结果放进输入框而不发送。
122
+ 你的草稿会被保留:**还原原文** 可恢复,且**会撤销"已改写"的记忆** —— 所以撤消后还能再改一次。**仅回填** 只把改写结果放进输入框而不发送。
124
123
 
125
124
  ### 发送拦截的原理与它唯一的风险
126
125
 
@@ -148,7 +147,7 @@ dsh plugin --profile web remove dsh-persona-forge
148
147
 
149
148
  代价是**延迟和 token 都翻倍**,所以默认关闭:最常见、最伤人的失败(丢掉文件名 / 数字 / 版本号)已经被本地检查免费证明了,为它每次发送都付一次模型调用是浪费。草稿偏技术时再开。
150
149
 
151
- 菜单里 **模型校验:开 / 关**,选择按会话记住。关闭时面板会写明「已做本地检查(不耗 token)」,并说清它**没有**覆盖什么 —— 一个窄的结论必须自己交代边界。
150
+ 开关在**设置 → 角色扮演**(「开启模型校验」),是个全局偏好 —— 它回答的是「我要不要为第二次调用付费」,一次决定即可,不必在每次发送前重新问一遍。关闭时面板会写明「已做本地检查(不耗 token)」,并说清它**没有**覆盖什么 —— 一个窄的结论必须自己交代边界。
152
151
 
153
152
  两层都是**建议性**的,绝不阻断改写:只标注,不拦你。
154
153
 
@@ -266,7 +265,7 @@ examplesByIntensity:
266
265
  - **收窄(允许)**:一张 `strategy` 卡可以临时要求「本次只改语气」。这只是**拿走**它追加约束的权力,所以任何情况下都更安全。
267
266
  - **放宽(拒绝)**:一张 `style` 卡**不能**被要求改成 `strategy`。放宽意味着让改写往你的需求里加你没写过的约束 —— 那是卡片作者的决定,不该由「发送前顺手一点」来授权。真要放宽,去编辑那张卡。
268
267
 
269
- 输入框菜单里的「保真度」默认**跟随卡片**;若卡片是 `style`,提示会写明这里无法放宽。服务端同样强制这条规则,所以界面上不会给你一个会被忽略的按钮。
268
+ 这个开关在「更多设置」里,且**只在卡片是 `strategy` 时出现**。`style` 卡上它不显示 —— 那种卡上两个选项效果完全一样,与其摆一个做不了事的控件、再配一段解释为什么不能放宽,不如不显示。服务端强制同一条规则,所以界面上不会给你一个会被忽略的按钮。
270
269
 
271
270
  ### `mode: template`
272
271
 
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': '中',
@@ -60,10 +62,6 @@ window.__ModuleLoader__.load({
60
62
  'control.fidelity.style': '只改语气',
61
63
  'control.fidelity.hint.style': '这张卡声明的是「只改语气」,不能在这里放宽——放宽是改卡的属性,请在设置页改。',
62
64
  'control.fidelity.hint.strategy': '这张卡允许追加行为约束。这里可以临时收窄成「只改语气」,但收不回去。',
63
- 'control.modelCheck': '模型校验',
64
- 'control.modelCheck.on': '开',
65
- 'control.modelCheck.off': '关',
66
- 'control.modelCheck.hint': '开启后每次改写会多调用一次模型,检查「需求被悄悄改弱」这类本地检查看不出的问题——代价是延迟和 token 翻倍。关闭时仍会做本地检查(标识符、路径、版本号、数字),不耗 token。',
67
65
  'panel.title': '角色改写',
68
66
  'panel.loading': '正在按「{name}」改写…',
69
67
  'panel.original': '你写的',
@@ -78,6 +76,7 @@ window.__ModuleLoader__.load({
78
76
  'panel.sent.hint': '消息已发出,无法撤回。下面是实际发出的内容。',
79
77
  'panel.meta': '{provider} / {model} · {ms}ms · 强度 {intensity}',
80
78
  'panel.template': '模板模式(未调用模型)',
79
+ 'panel.slow': '已经 {seconds} 秒了 —— 模型可能较慢。再等一会儿通常会有结果;若一直不动,可关闭面板后重试。',
81
80
  'panel.drift': '技术事实可能被改动',
82
81
  'panel.checkUnavailable': '未完成事实校验',
83
82
  'panel.checkOk': '技术事实已保留',
@@ -88,7 +87,6 @@ window.__ModuleLoader__.load({
88
87
  'panel.stale': '草稿在改写期间有改动——结果基于改写前的文本。',
89
88
  'error.empty': '输入框是空的,先写下你的需求。',
90
89
  'error.phase': '输入框正忙(提交中),请稍后再试。',
91
- 'error.busy': '已有一个改写在进行中。',
92
90
  'error.noCard': '请先选择一个角色。',
93
91
  'error.unconfigured': '无法确定模型:先在本会话发一条消息,或设置一个默认模型。',
94
92
  'error.timeout': '改写超时,请重试;原输入未改动。',
@@ -104,7 +102,6 @@ window.__ModuleLoader__.load({
104
102
  'settings.ai.tip': '新建角色卡时可以让 AI 代填:写一句角色思路,风格和四档范例都会自动写好。',
105
103
  'settings.ai.model': '使用模型:{provider} / {model}(设置页没有会话上下文,用的是 harness 默认模型;改写本身跟随当前会话的模型)',
106
104
  'settings.ai.desc': '用一句话描述角色的思路,AI 会按同一套规范把表单填好:风格、四档强度的转换范例都写好。生成结果只填入表单,不会自动保存。',
107
- 'settings.ai.briefLabel': '角色思路(一句话或几句话都行)',
108
105
  'settings.ai.briefPlaceholder': '例如:一个脾气暴躁但极其可靠的老灯塔守夜人,说话简短、爱用航海比喻',
109
106
  'settings.ai.generate': '生成草稿',
110
107
  'settings.ai.busy': 'AI 正在撰写角色卡…',
@@ -126,7 +123,6 @@ window.__ModuleLoader__.load({
126
123
  'settings.check.none': '所有角色卡都与内置版本一致。',
127
124
  'settings.check.some': '有 {n} 张角色卡与内置版本不一致:',
128
125
  'settings.check.desc': '点一次即可查出哪些卡偏离了内置版本。只有不一致的卡才显示「还原为内置」。',
129
- 'settings.changed': '改动:',
130
126
  'settings.save': '保存',
131
127
  'settings.cancel': '取消',
132
128
  'settings.saved': '已保存',
@@ -162,8 +158,10 @@ window.__ModuleLoader__.load({
162
158
  'control.busy': 'Rewriting…',
163
159
  'control.menu.title': 'Choose a character',
164
160
  'control.menu.empty': 'No character cards yet. Drop a .yml file into the card directory to add one.',
165
- 'control.menu.manage': 'Manage cards…',
166
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: ',
167
165
  'control.intensity': 'Intensity',
168
166
  'control.intensity.light': 'Light',
169
167
  'control.intensity.medium': 'Medium',
@@ -184,10 +182,6 @@ window.__ModuleLoader__.load({
184
182
  'control.fidelity.style': 'Voice only',
185
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.',
186
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.',
187
- 'control.modelCheck': 'Model check',
188
- 'control.modelCheck.on': 'On',
189
- 'control.modelCheck.off': 'Off',
190
- 'control.modelCheck.hint': 'On: every rewrite makes one extra model call to catch what the local check cannot see, such as a requirement quietly weakened — at the cost of doubled latency and tokens. Off: the free local check still runs (identifiers, paths, versions, numbers).',
191
185
  'panel.title': 'Persona rewrite',
192
186
  'panel.loading': 'Rewriting as “{name}”…',
193
187
  'panel.original': 'You wrote',
@@ -202,6 +196,7 @@ window.__ModuleLoader__.load({
202
196
  'panel.sent.hint': 'The message is already sent and cannot be recalled. This is what was sent.',
203
197
  'panel.meta': '{provider} / {model} · {ms}ms · intensity {intensity}',
204
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.',
205
200
  'panel.drift': 'Technical facts may have changed',
206
201
  'panel.checkUnavailable': 'Preservation check did not complete',
207
202
  'panel.checkOk': 'Technical facts preserved',
@@ -212,7 +207,6 @@ window.__ModuleLoader__.load({
212
207
  'panel.stale': 'The draft changed while rewriting — the result is based on the earlier text.',
213
208
  'error.empty': 'The input box is empty — write your request first.',
214
209
  'error.phase': 'The input box is busy (submitting) — try again shortly.',
215
- 'error.busy': 'A rewrite is already running.',
216
210
  'error.noCard': 'Choose a character first.',
217
211
  'error.unconfigured': 'No model resolved: send a message in this session first, or set a default model.',
218
212
  'error.timeout': 'The rewrite timed out. Retry; your draft is untouched.',
@@ -228,7 +222,6 @@ window.__ModuleLoader__.load({
228
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.',
229
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)',
230
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.',
231
- 'settings.ai.briefLabel': 'Character idea (a sentence or a few)',
232
225
  'settings.ai.briefPlaceholder': 'e.g. a grumpy but utterly reliable old lighthouse keeper who speaks in short sentences and nautical metaphors',
233
226
  'settings.ai.generate': 'Generate draft',
234
227
  'settings.ai.busy': 'Writing the character card…',
@@ -250,7 +243,6 @@ window.__ModuleLoader__.load({
250
243
  'settings.check.none': 'Every character card matches its bundled version.',
251
244
  'settings.check.some': '{n} card(s) differ from the bundled version:',
252
245
  'settings.check.desc': 'Finds which cards have drifted from the bundled version. Only a drifted card shows a Restore bundled action.',
253
- 'settings.changed': 'Changed:',
254
246
  'settings.save': 'Save',
255
247
  'settings.cancel': 'Cancel',
256
248
  'settings.saved': 'Saved',
@@ -333,6 +325,15 @@ window.__ModuleLoader__.load({
333
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)}
334
326
  .pf-menu-title{padding:6px 8px 4px;font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}
335
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
+
336
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}
337
338
  .pf-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
338
339
  .pf-item:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}
@@ -500,8 +501,6 @@ window.__ModuleLoader__.load({
500
501
  const intensityKey = (key) => `persona-forge:intensity:${key}`
501
502
  const modeKey = (key) => `persona-forge:mode:${key}`
502
503
  const fidelityKey = (key) => `persona-forge:fidelity:${key}`
503
- const modelCheckKey = (key) => `persona-forge:modelCheck:${key}`
504
-
505
504
  /** Effective mode for a card: the session's pick when the card can honour it, else the card's. */
506
505
  function effectiveModeOf(card, override) {
507
506
  if (card === null) return 'llm'
@@ -674,6 +673,7 @@ window.__ModuleLoader__.load({
674
673
  panel: {
675
674
  sessionKey: options.sessionKey,
676
675
  phase: 'loading',
676
+ startedAt: Date.now(),
677
677
  cardId: options.cardId,
678
678
  cardName: options.cardName,
679
679
  original: options.original,
@@ -754,14 +754,10 @@ window.__ModuleLoader__.load({
754
754
  // stored as "unset" until the user actually picks something, so following
755
755
  // the card stays the default rather than an explicit choice.
756
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)
757
760
  const [fidelityOverride, setFidelityOverride] = useState(() => readLocal(fidelityKey(sessionKey)))
758
- // Tri-state: undefined = follow the host config, true/false = this
759
- // session's explicit choice. Stored as a string because localStorage has
760
- // no booleans.
761
- const [modelCheckOverride, setModelCheckOverride] = useState(() => {
762
- const stored = readLocal(modelCheckKey(sessionKey))
763
- return stored === 'on' ? true : stored === 'off' ? false : undefined
764
- })
765
761
 
766
762
  useEffect(() => {
767
763
  if (shared.catalog === null && shared.catalogError === null) {
@@ -777,7 +773,20 @@ window.__ModuleLoader__.load({
777
773
  const effectiveIntensity = intensity ?? selected?.intensity ?? 'medium'
778
774
  const effectiveMode = effectiveModeOf(selected, modeOverride)
779
775
  const effectiveFidelity = effectiveFidelityOf(selected, fidelityOverride)
780
- const effectiveModelCheck = modelCheckOverride ?? shared.factCheck
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(' · ')
781
790
 
782
791
  // The send-gesture listener is attached once per session but must read
783
792
  // CURRENT values. A ref carries them across renders without re-attaching
@@ -827,11 +836,6 @@ window.__ModuleLoader__.load({
827
836
  writeLocal(fidelityKey(sessionKey), fidelity)
828
837
  }, [sessionKey])
829
838
 
830
- const setModelCheck = useCallback((enabled) => {
831
- setModelCheckOverride(enabled)
832
- writeLocal(modelCheckKey(sessionKey), enabled ? 'on' : 'off')
833
- }, [sessionKey])
834
-
835
839
  // Position the menu above the button, clamped to the viewport.
836
840
  useEffect(() => {
837
841
  if (!open) return
@@ -1028,26 +1032,6 @@ window.__ModuleLoader__.load({
1028
1032
  style: { left: `${menuPos.left}px`, bottom: `${menuPos.bottom}px`, width: `${menuPos.width}px` },
1029
1033
  },
1030
1034
  h('div', { className: 'pf-menu-title' }, t('control.menu.title')),
1031
- // "No persona" is a real choice, listed like any card, so the
1032
- // control can always be turned back off.
1033
- h(
1034
- 'button',
1035
- {
1036
- type: 'button',
1037
- role: 'menuitemradio',
1038
- 'aria-checked': isOff ? 'true' : 'false',
1039
- className: `pf-item${isOff ? ' is-selected' : ''}`,
1040
- onClick: () => pickCard(OFF),
1041
- },
1042
- h('span', { className: 'pf-item-icon', 'aria-hidden': true }, '🚫'),
1043
- h(
1044
- 'span',
1045
- { className: 'pf-item-body' },
1046
- h('span', { className: 'pf-item-name' }, t('control.none')),
1047
- h('span', { className: 'pf-item-desc' }, t('control.none.desc')),
1048
- ),
1049
- isOff ? h('span', { className: 'pf-check', 'aria-hidden': true }, '✓') : null,
1050
- ),
1051
1035
  cards.length === 0
1052
1036
  ? h('div', { className: 'pf-hint' }, shared.catalogError ?? t('control.menu.empty'))
1053
1037
  : cards.map((card) => h(
@@ -1071,6 +1055,28 @@ window.__ModuleLoader__.load({
1071
1055
  ),
1072
1056
  card.id === cardId ? h('span', { className: 'pf-check', 'aria-hidden': true }, '✓') : null,
1073
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
+ ),
1074
1080
  h('div', { className: 'pf-menu-sep' }),
1075
1081
  h(
1076
1082
  'div',
@@ -1112,80 +1118,90 @@ window.__ModuleLoader__.load({
1112
1118
  ),
1113
1119
  ),
1114
1120
  h('div', { className: 'pf-hint' }, t(`control.sendMode.hint.${effectiveSendMode}`)),
1115
- // Mode and fidelity, per session. Both defer to the card until the
1116
- // user picks: following the card is the default, not a choice the
1117
- // user has to rediscover.
1118
- h(
1119
- 'div',
1120
- { className: 'pf-row' },
1121
- h('span', { className: 'pf-row-label' }, t('control.mode')),
1122
- h(
1123
- 'span',
1124
- { className: 'pf-seg' },
1125
- [['', t('control.mode.card')], ['llm', t('control.mode.llm')], ['template', t('control.mode.template')]].map(([value, label]) => h(
1126
- 'button',
1127
- {
1128
- key: value === '' ? 'card' : value,
1129
- type: 'button',
1130
- 'aria-pressed': (modeOverride ?? '') === value ? 'true' : 'false',
1131
- className: (modeOverride ?? '') === value ? 'is-on' : '',
1132
- onClick: () => setMode(value === '' ? undefined : value),
1133
- },
1134
- label,
1135
- )),
1136
- ),
1137
- ),
1138
- h('div', { className: 'pf-hint' }, t('control.mode.hint')),
1139
- h(
1140
- 'div',
1141
- { className: 'pf-row' },
1142
- h('span', { className: 'pf-row-label' }, t('control.fidelity')),
1143
- h(
1144
- 'span',
1145
- { className: 'pf-seg' },
1146
- [['', t('control.fidelity.card')], ['style', t('control.fidelity.style')]].map(([value, label]) => h(
1147
- 'button',
1148
- {
1149
- key: value === '' ? 'card' : value,
1150
- type: 'button',
1151
- 'aria-pressed': (fidelityOverride ?? '') === value ? 'true' : 'false',
1152
- className: (fidelityOverride ?? '') === value ? 'is-on' : '',
1153
- onClick: () => setFidelity(value === '' ? undefined : value),
1154
- },
1155
- label,
1156
- )),
1157
- ),
1158
- ),
1159
- // A voice-only card can never be widened from here, so the hint
1160
- // says which card-level rule is in force rather than offering a
1161
- // control that would be ignored.
1162
- h('div', { className: 'pf-hint' }, selected?.fidelity === 'strategy'
1163
- ? t('control.fidelity.hint.strategy')
1164
- : t('control.fidelity.hint.style')),
1165
- // The model preservation check costs a second call per send, so
1166
- // it is a visible switch rather than a hidden config file edit.
1167
- // The free local check runs either way and cannot be turned off.
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.
1168
1129
  h(
1169
- 'div',
1170
- { className: 'pf-row' },
1171
- h('span', { className: 'pf-row-label' }, t('control.modelCheck')),
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 ? '▾' : '▸'),
1172
1139
  h(
1173
1140
  'span',
1174
- { className: 'pf-seg' },
1175
- [[true, t('control.modelCheck.on')], [false, t('control.modelCheck.off')]].map(([value, label]) => h(
1176
- 'button',
1177
- {
1178
- key: String(value),
1179
- type: 'button',
1180
- 'aria-pressed': effectiveModelCheck === value ? 'true' : 'false',
1181
- className: effectiveModelCheck === value ? 'is-on' : '',
1182
- onClick: () => setModelCheck(value),
1183
- },
1184
- label,
1185
- )),
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,
1186
1146
  ),
1187
1147
  ),
1188
- h('div', { className: 'pf-hint' }, t('control.modelCheck.hint')),
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,
1189
1205
  h('div', { className: 'pf-menu-sep' }),
1190
1206
  h(
1191
1207
  'button',
@@ -1218,6 +1234,23 @@ window.__ModuleLoader__.load({
1218
1234
  const panel = shared.panel
1219
1235
  const inputActions = props.inputActions
1220
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
1221
1254
  const submit = typeof inputActions?.submit === 'function' ? inputActions.submit : null
1222
1255
  const owned = panel !== null && panel.sessionKey === sessionKey ? panel : null
1223
1256
  // Guards the direct-send effect against firing twice for one result.
@@ -1250,6 +1283,12 @@ window.__ModuleLoader__.load({
1250
1283
  /** Replace the composer content, then optionally submit it. */
1251
1284
  const apply = (text, thenSubmit) => {
1252
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)
1253
1292
  setDraft(text)
1254
1293
  if (thenSubmit !== true || submit === null) {
1255
1294
  closePanel()
@@ -1281,6 +1320,7 @@ window.__ModuleLoader__.load({
1281
1320
  )
1282
1321
 
1283
1322
  if (panel.phase === 'loading') {
1323
+ const seconds = elapsedSeconds ?? 0
1284
1324
  return h(
1285
1325
  'div',
1286
1326
  { className: 'pf-panel' },
@@ -1288,7 +1328,17 @@ window.__ModuleLoader__.load({
1288
1328
  h(
1289
1329
  'div',
1290
1330
  { className: 'pf-panel-body' },
1291
- h('div', { className: 'pf-meta' }, t('panel.loading', { name: panel.cardName ?? panel.cardId })),
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,
1292
1342
  ),
1293
1343
  )
1294
1344
  }
@@ -1673,9 +1723,11 @@ window.__ModuleLoader__.load({
1673
1723
  // new-card flow; this line is what tells a user it exists, so they do
1674
1724
  // not have to press a button to find out.
1675
1725
  h('div', { className: 'pf-small', style: { margin: '0 0 10px' } }, t('settings.ai.tip')),
1676
- // The check result, stated once above the list.
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.
1677
1729
  drift === null
1678
- ? h('div', { className: 'pf-small', style: { marginBottom: '10px' } }, t('settings.check.desc'))
1730
+ ? null
1679
1731
  : drift === undefined
1680
1732
  ? h('div', { className: 'pf-note is-muted', style: { marginBottom: '10px' } }, t('settings.check.busy'))
1681
1733
  : drift.length === 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-persona-forge",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "给 DeepSeek Harness 用的角色扮演提示词改写插件:在输入框选一张角色卡,草稿就会由 harness 的模型改写成该角色的口吻(跟随当前会话正在使用的模型),然后直接发出或先人工审查。原草稿始终保留。",
5
5
  "type": "module",
6
6
  "license": "MIT",