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/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.builtin': '内置',
86
- 'settings.user': '自定义',
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.builtin': 'Built-in',
176
- 'settings.user': 'Custom',
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
- factCheck: true,
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 !== false,
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('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,
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
- if (!window.confirm(t('settings.deleteConfirm'))) return
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: () => { setForm({ ...BLANK_CARD }) ; setStatus(null) } }, t('settings.new')),
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 check result, stated once above the list.
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
- ? h('div', { className: 'pf-small', style: { marginBottom: '10px' } }, t('settings.check.desc'))
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: () => setForm(null) }, t('settings.cancel')),
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' }, card.builtin === true ? t('settings.builtin') : t('settings.user')),
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
- h('button', { type: 'button', className: 'pf-action', onClick: () => remove(card) }, t('settings.delete')),
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
  )