dsh-persona-forge 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -50,6 +50,20 @@ window.__ModuleLoader__.load({
50
50
  'control.sendMode.direct': '直接发出',
51
51
  'control.sendMode.hint.review': '改写结果先在下方的对比面板显示,你确认后再发送;原草稿不受影响。',
52
52
  'control.sendMode.hint.direct': '改写完成后立即作为你的消息发出,不再确认。',
53
+ 'control.mode': '改写方式',
54
+ 'control.mode.card': '跟随卡片',
55
+ 'control.mode.llm': '模型改写',
56
+ 'control.mode.template': '模板套用',
57
+ 'control.mode.hint': '跟随卡片 = 用卡片自己声明的方式。临时切换只影响这个会话。',
58
+ 'control.fidelity': '保真度',
59
+ 'control.fidelity.card': '跟随卡片',
60
+ 'control.fidelity.style': '只改语气',
61
+ 'control.fidelity.hint.style': '这张卡声明的是「只改语气」,不能在这里放宽——放宽是改卡的属性,请在设置页改。',
62
+ 'control.fidelity.hint.strategy': '这张卡允许追加行为约束。这里可以临时收窄成「只改语气」,但收不回去。',
63
+ 'control.modelCheck': '模型校验',
64
+ 'control.modelCheck.on': '开',
65
+ 'control.modelCheck.off': '关',
66
+ 'control.modelCheck.hint': '开启后每次改写会多调用一次模型,检查「需求被悄悄改弱」这类本地检查看不出的问题——代价是延迟和 token 翻倍。关闭时仍会做本地检查(标识符、路径、版本号、数字),不耗 token。',
53
67
  'panel.title': '角色改写',
54
68
  'panel.loading': '正在按「{name}」改写…',
55
69
  'panel.original': '你写的',
@@ -67,6 +81,10 @@ window.__ModuleLoader__.load({
67
81
  'panel.drift': '技术事实可能被改动',
68
82
  'panel.checkUnavailable': '未完成事实校验',
69
83
  'panel.checkOk': '技术事实已保留',
84
+ 'panel.factsDrift': '检测到技术内容变动',
85
+ 'panel.factsMissing': '丢失:',
86
+ 'panel.factsAdded': '新增:',
87
+ 'panel.factsOnly': '已做本地检查(不耗 token):标识符、路径、版本号、数字均未丢失。但它看不出「需求被悄悄改弱」或「用散文追加了约束」——要覆盖这些请开启模型校验。',
70
88
  'panel.stale': '草稿在改写期间有改动——结果基于改写前的文本。',
71
89
  'error.empty': '输入框是空的,先写下你的需求。',
72
90
  'error.phase': '输入框正忙(提交中),请稍后再试。',
@@ -82,11 +100,24 @@ window.__ModuleLoader__.load({
82
100
  'settings.dir': '角色卡目录',
83
101
  'settings.reveal': '打开目录',
84
102
  'settings.new': '新建角色卡',
85
- 'settings.builtin': '内置',
86
- 'settings.user': '自定义',
103
+ 'settings.ai': 'AI 代填',
104
+ 'settings.ai.tip': '新建角色卡时可以让 AI 代填:写一句角色思路,风格和四档范例都会自动写好。',
105
+ 'settings.ai.model': '使用模型:{provider} / {model}(设置页没有会话上下文,用的是 harness 默认模型;改写本身跟随当前会话的模型)',
106
+ 'settings.ai.desc': '用一句话描述角色的思路,AI 会按同一套规范把表单填好:风格、四档强度的转换范例都写好。生成结果只填入表单,不会自动保存。',
107
+ 'settings.ai.briefLabel': '角色思路(一句话或几句话都行)',
108
+ 'settings.ai.briefPlaceholder': '例如:一个脾气暴躁但极其可靠的老灯塔守夜人,说话简短、爱用航海比喻',
109
+ 'settings.ai.generate': '生成草稿',
110
+ 'settings.ai.busy': 'AI 正在撰写角色卡…',
111
+ 'settings.ai.hint': '生成的是草稿,改完再保存。',
112
+ 'settings.ai.needBrief': '请先写下角色的思路。',
113
+ 'settings.ai.failed': '生成失败:{message}',
114
+ 'settings.origin.builtin': '内置',
115
+ 'settings.origin.edited': '已修改',
116
+ 'settings.origin.custom': '自定义',
87
117
  'settings.edit': '编辑',
88
118
  'settings.delete': '删除',
89
119
  'settings.deleteConfirm': '确定删除这张角色卡?',
120
+ 'settings.deleteEditedConfirm': '这张卡改自内置版本。删除会丢弃你的修改,恢复成内置版本。继续?',
90
121
  'settings.reset': '还原为内置',
91
122
  'settings.resetConfirm': '用插件内置的版本覆盖这张卡?你对它的本地修改会丢失。',
92
123
  'settings.resetHint': '把这张卡恢复成插件内置的版本,丢弃本地修改。',
@@ -115,6 +146,9 @@ window.__ModuleLoader__.load({
115
146
  'settings.field.style': '角色风格描述(给改写模型的指令)',
116
147
  'settings.field.template': '模板(用 {{input}} 代表你的原话)',
117
148
  'settings.field.examples': '转换范例(每行一条,格式:原话 => 改写后)',
149
+ 'settings.field.byIntensity': '分强度范例(每档各一组;留空则回退到上面的通用范例)',
150
+ 'settings.field.byIntensity.empty': '留空 = 这一档沿用上面的通用范例',
151
+ 'settings.hint.byIntensity': '四档强度各自用哪组范例。范例越贴合该档语气,改写越稳定。留空的档位会回退到通用范例。',
118
152
  'settings.hint.fidelity': '只改语气:只换说法,需求一字不动。允许增加行为约束:改写时还能追加符合角色的做事风格(例如「别做冗长规划」)——这会真的改变你的需求,建议配合「先审查」。',
119
153
  'settings.hint.examples': '范例是最强的风格锚点。每条范例里原话的技术事实必须出现在改写结果中——否则模型会学会"用氛围替换需求"。',
120
154
  'settings.diagnostics': '加载问题',
@@ -140,6 +174,20 @@ window.__ModuleLoader__.load({
140
174
  'control.sendMode.direct': 'Send directly',
141
175
  'control.sendMode.hint.review': 'The rewrite appears in the comparison panel below; you confirm before it is sent, and your draft is untouched.',
142
176
  'control.sendMode.hint.direct': 'The rewrite is sent as your message immediately, without confirmation.',
177
+ 'control.mode': 'Rewrite mode',
178
+ 'control.mode.card': 'Card default',
179
+ 'control.mode.llm': 'Model rewrite',
180
+ 'control.mode.template': 'Template',
181
+ 'control.mode.hint': 'Card default = use whatever the card declares. A temporary switch applies to this session only.',
182
+ 'control.fidelity': 'Fidelity',
183
+ 'control.fidelity.card': 'Card default',
184
+ 'control.fidelity.style': 'Voice only',
185
+ '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
+ '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).',
143
191
  'panel.title': 'Persona rewrite',
144
192
  'panel.loading': 'Rewriting as “{name}”…',
145
193
  'panel.original': 'You wrote',
@@ -157,6 +205,10 @@ window.__ModuleLoader__.load({
157
205
  'panel.drift': 'Technical facts may have changed',
158
206
  'panel.checkUnavailable': 'Preservation check did not complete',
159
207
  'panel.checkOk': 'Technical facts preserved',
208
+ 'panel.factsDrift': 'Technical content changed',
209
+ 'panel.factsMissing': 'Lost: ',
210
+ 'panel.factsAdded': 'Added: ',
211
+ '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
212
  'panel.stale': 'The draft changed while rewriting — the result is based on the earlier text.',
161
213
  'error.empty': 'The input box is empty — write your request first.',
162
214
  'error.phase': 'The input box is busy (submitting) — try again shortly.',
@@ -172,11 +224,24 @@ window.__ModuleLoader__.load({
172
224
  'settings.dir': 'Card directory',
173
225
  'settings.reveal': 'Open directory',
174
226
  'settings.new': 'New character card',
175
- 'settings.builtin': 'Built-in',
176
- 'settings.user': 'Custom',
227
+ 'settings.ai': 'AI draft',
228
+ '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
+ '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
+ '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
+ 'settings.ai.briefPlaceholder': 'e.g. a grumpy but utterly reliable old lighthouse keeper who speaks in short sentences and nautical metaphors',
233
+ 'settings.ai.generate': 'Generate draft',
234
+ 'settings.ai.busy': 'Writing the character card…',
235
+ 'settings.ai.hint': 'This is a draft — edit it before saving.',
236
+ 'settings.ai.needBrief': 'Write the character idea first.',
237
+ 'settings.ai.failed': 'Generation failed: {message}',
238
+ 'settings.origin.builtin': 'Built-in',
239
+ 'settings.origin.edited': 'Edited',
240
+ 'settings.origin.custom': 'Custom',
177
241
  'settings.edit': 'Edit',
178
242
  'settings.delete': 'Delete',
179
243
  'settings.deleteConfirm': 'Delete this character card?',
244
+ 'settings.deleteEditedConfirm': 'This card is an edit of a bundled one. Deleting it discards your changes and restores the bundled version. Continue?',
180
245
  'settings.reset': 'Restore bundled',
181
246
  'settings.resetConfirm': 'Replace this card with the bundled version? Your local edits to it will be lost.',
182
247
  'settings.resetHint': 'Restore this card to the version the plugin ships, discarding local edits.',
@@ -205,6 +270,9 @@ window.__ModuleLoader__.load({
205
270
  'settings.field.style': 'Character style (instructions for the rewriting model)',
206
271
  'settings.field.template': 'Template (use {{input}} for the user\'s text)',
207
272
  'settings.field.examples': 'Conversion examples (one per line, format: original => rewritten)',
273
+ 'settings.field.byIntensity': 'Per-intensity examples (one set each; empty falls back to the list above)',
274
+ 'settings.field.byIntensity.empty': 'Empty = this level reuses the examples above',
275
+ '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
276
  '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
277
  '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
278
  'settings.diagnostics': 'Load problems',
@@ -327,6 +395,11 @@ window.__ModuleLoader__.load({
327
395
  .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
396
  .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
397
  .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)}
398
+ /* The AI box inside the new-card form: a quiet inset panel, not a second
399
+ * competing call to action. */
400
+ .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}
401
+ .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}
402
+ .pf-ai-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
330
403
  .pf-field{display:grid;gap:4px}
331
404
  .pf-field label{font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}
332
405
  .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 +466,7 @@ window.__ModuleLoader__.load({
393
466
  const resetCard = (id) => postJson(`${PREFIX}/cards/reset`, { id })
394
467
  const diffCards = () => postJson(`${PREFIX}/cards/diff`, {})
395
468
  const revealDir = () => postJson(`${PREFIX}/reveal`, {})
469
+ const draftCard = (payload) => postJson(`${PREFIX}/cards/draft`, payload)
396
470
 
397
471
  // ---------------------------------------------------------------------
398
472
  // Shared UI state
@@ -424,6 +498,28 @@ window.__ModuleLoader__.load({
424
498
  const sendModeKey = (key) => `persona-forge:sendMode:${key}`
425
499
  const cardKey = (key) => `persona-forge:card:${key}`
426
500
  const intensityKey = (key) => `persona-forge:intensity:${key}`
501
+ const modeKey = (key) => `persona-forge:mode:${key}`
502
+ const fidelityKey = (key) => `persona-forge:fidelity:${key}`
503
+ const modelCheckKey = (key) => `persona-forge:modelCheck:${key}`
504
+
505
+ /** Effective mode for a card: the session's pick when the card can honour it, else the card's. */
506
+ function effectiveModeOf(card, override) {
507
+ if (card === null) return 'llm'
508
+ if (override === 'template') return typeof card.template === 'string' && card.template.trim() !== '' ? 'template' : card.mode
509
+ if (override === 'llm') return typeof card.style === 'string' && card.style.trim() !== '' ? 'llm' : card.mode
510
+ return card.mode
511
+ }
512
+
513
+ /**
514
+ * Effective fidelity for a card: the session may NARROW a strategy card to
515
+ * voice-only, never widen a voice-only card to strategy. The server enforces
516
+ * the same rule; this keeps the UI from offering a choice it would ignore.
517
+ */
518
+ function effectiveFidelityOf(card, override) {
519
+ if (card === null) return 'style'
520
+ if (card.fidelity === 'strategy' && override === 'style') return 'style'
521
+ return card.fidelity
522
+ }
427
523
 
428
524
  /**
429
525
  * The "no persona" selection. It is a real, persisted state rather than the
@@ -511,7 +607,11 @@ window.__ModuleLoader__.load({
511
607
  diagnostics: [],
512
608
  directory: '',
513
609
  hostSendMode: 'review',
514
- factCheck: true,
610
+ // Mirrors the host default in lib/index.js: the model check is opt-in
611
+ // because it is paid for on every send, and the local check always runs.
612
+ factCheck: false,
613
+ /** Which model an AI draft would use, as a ready-to-render sentence. */
614
+ draftModelLabel: '',
515
615
  panel: null,
516
616
  }
517
617
  function notify(next) {
@@ -539,7 +639,13 @@ window.__ModuleLoader__.load({
539
639
  diagnostics: Array.isArray(value.diagnostics) ? value.diagnostics : [],
540
640
  directory: typeof value.directory === 'string' ? value.directory : '',
541
641
  hostSendMode: value.sendMode === 'direct' ? 'direct' : 'review',
542
- factCheck: value.factCheck !== false,
642
+ factCheck: value.factCheck === true,
643
+ // Say which model an AI draft runs on. The settings slot is
644
+ // root-scoped and receives no session id, so this is the harness
645
+ // default — stating it beats letting the user assume.
646
+ draftModelLabel: value.draftModel === null || value.draftModel === undefined
647
+ ? ''
648
+ : t('settings.ai.model', { provider: value.draftModel.provider, model: value.draftModel.model }),
543
649
  catalogError: null,
544
650
  })
545
651
  return state.catalog
@@ -572,6 +678,9 @@ window.__ModuleLoader__.load({
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,18 @@ 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
+ 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
+ })
641
765
 
642
766
  useEffect(() => {
643
767
  if (shared.catalog === null && shared.catalogError === null) {
@@ -651,6 +775,9 @@ window.__ModuleLoader__.load({
651
775
  const selected = isOff ? null : cards.find((card) => card.id === cardId) ?? null
652
776
  const busy = shared.panel !== null && shared.panel.phase === 'loading'
653
777
  const effectiveIntensity = intensity ?? selected?.intensity ?? 'medium'
778
+ const effectiveMode = effectiveModeOf(selected, modeOverride)
779
+ const effectiveFidelity = effectiveFidelityOf(selected, fidelityOverride)
780
+ const effectiveModelCheck = modelCheckOverride ?? shared.factCheck
654
781
 
655
782
  // The send-gesture listener is attached once per session but must read
656
783
  // CURRENT values. A ref carries them across renders without re-attaching
@@ -690,6 +817,21 @@ window.__ModuleLoader__.load({
690
817
  writeLocal(intensityKey(sessionKey), level)
691
818
  }, [sessionKey])
692
819
 
820
+ const setMode = useCallback((mode) => {
821
+ setModeOverride(mode)
822
+ writeLocal(modeKey(sessionKey), mode)
823
+ }, [sessionKey])
824
+
825
+ const setFidelity = useCallback((fidelity) => {
826
+ setFidelityOverride(fidelity)
827
+ writeLocal(fidelityKey(sessionKey), fidelity)
828
+ }, [sessionKey])
829
+
830
+ const setModelCheck = useCallback((enabled) => {
831
+ setModelCheckOverride(enabled)
832
+ writeLocal(modelCheckKey(sessionKey), enabled ? 'on' : 'off')
833
+ }, [sessionKey])
834
+
693
835
  // Position the menu above the button, clamped to the viewport.
694
836
  useEffect(() => {
695
837
  if (!open) return
@@ -745,9 +887,12 @@ window.__ModuleLoader__.load({
745
887
  cardName: selected.name,
746
888
  original: text,
747
889
  intensity: effectiveIntensity,
890
+ mode: effectiveMode,
891
+ fidelity: effectiveFidelity,
892
+ modelCheck: effectiveModelCheck,
748
893
  sendMode: effectiveSendMode,
749
894
  })
750
- }, [busy, cardId, draft, effectiveIntensity, effectiveSendMode, phase, props.sessionId, selected, sessionKey])
895
+ }, [busy, cardId, draft, effectiveFidelity, effectiveIntensity, effectiveMode, effectiveModelCheck, effectiveSendMode, phase, props.sessionId, selected, sessionKey])
751
896
 
752
897
  // Intercept the composer's OWN send action.
753
898
  //
@@ -967,6 +1112,80 @@ window.__ModuleLoader__.load({
967
1112
  ),
968
1113
  ),
969
1114
  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.
1168
+ h(
1169
+ 'div',
1170
+ { className: 'pf-row' },
1171
+ h('span', { className: 'pf-row-label' }, t('control.modelCheck')),
1172
+ h(
1173
+ '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
+ )),
1186
+ ),
1187
+ ),
1188
+ h('div', { className: 'pf-hint' }, t('control.modelCheck.hint')),
970
1189
  h('div', { className: 'pf-menu-sep' }),
971
1190
  h(
972
1191
  'button',
@@ -1100,18 +1319,33 @@ window.__ModuleLoader__.load({
1100
1319
  const text = typeof result.text === 'string' ? result.text : ''
1101
1320
  const isTemplate = result.mode === 'template'
1102
1321
  const check = result.factCheck ?? { state: 'skipped' }
1322
+ const facts = result.facts ?? { state: 'ok', missing: [], added: [] }
1103
1323
  const stale = typeof draft === 'string' && draft !== panel.original
1104
1324
  const direct = panel.sendMode === 'direct'
1105
1325
  const intensityLabel = t(`control.intensity.${result.intensity ?? panel.intensity ?? 'medium'}`)
1106
1326
 
1107
1327
  const notes = []
1108
1328
  if (stale && !direct) notes.push(h('div', { key: 'stale', className: 'pf-note is-muted' }, t('panel.stale')))
1329
+ // The deterministic verdict comes first: it is always available, it costs
1330
+ // nothing, and it names the exact token that went missing — which is more
1331
+ // actionable than a model's sentence about it.
1332
+ if (facts.state === 'drift') {
1333
+ const parts = []
1334
+ if (facts.missing.length > 0) parts.push(`${t('panel.factsMissing')}${facts.missing.join('、')}`)
1335
+ if (facts.added.length > 0) parts.push(`${t('panel.factsAdded')}${facts.added.join('、')}`)
1336
+ notes.push(h('div', { key: 'facts', className: 'pf-note is-warn' }, `${t('panel.factsDrift')} — ${parts.join(';')}`))
1337
+ }
1109
1338
  if (check.state === 'drift') {
1110
1339
  notes.push(h('div', { key: 'drift', className: 'pf-note is-warn' }, `${t('panel.drift')}${check.reason !== undefined ? `:${check.reason}` : ''}`))
1111
1340
  } else if (check.state === 'unavailable') {
1112
1341
  notes.push(h('div', { key: 'unavail', className: 'pf-note is-muted' }, `${t('panel.checkUnavailable')}${check.reason !== undefined ? `:${check.reason}` : ''}`))
1113
1342
  } else if (check.state === 'ok') {
1114
1343
  notes.push(h('div', { key: 'ok', className: 'pf-note is-ok' }, t('panel.checkOk')))
1344
+ } else if (check.state === 'skipped' && facts.state === 'ok') {
1345
+ // With the model check off, the free check still reported. Say so, and
1346
+ // say plainly what it did NOT cover — a clean verdict here is narrower
1347
+ // than a clean verdict with the model check on.
1348
+ notes.push(h('div', { key: 'factsonly', className: 'pf-note is-muted' }, t('panel.factsOnly')))
1115
1349
  }
1116
1350
  if (direct) notes.push(h('div', { key: 'sent', className: 'pf-note is-muted' }, `${t('panel.sent')} — ${t('panel.sent.hint')}`))
1117
1351
 
@@ -1171,6 +1405,9 @@ window.__ModuleLoader__.load({
1171
1405
  cardName: panel.cardName,
1172
1406
  original: panel.original,
1173
1407
  intensity: panel.intensity,
1408
+ mode: panel.mode,
1409
+ fidelity: panel.fidelity,
1410
+ modelCheck: panel.modelCheck,
1174
1411
  sendMode: panel.sendMode,
1175
1412
  }),
1176
1413
  },
@@ -1196,6 +1433,7 @@ window.__ModuleLoader__.load({
1196
1433
  style: '',
1197
1434
  template: '',
1198
1435
  examplesText: '',
1436
+ byIntensityText: { light: '', medium: '', strong: '', zealot: '' },
1199
1437
  }
1200
1438
 
1201
1439
  /** Render a card into the form's flat shape. */
@@ -1211,6 +1449,16 @@ window.__ModuleLoader__.load({
1211
1449
  style: card.style ?? '',
1212
1450
  template: card.template ?? '',
1213
1451
  examplesText: (card.examples ?? []).map((example) => `${example.from} => ${example.to}`).join('\n'),
1452
+ // The per-intensity demonstrations are part of the card, so the form
1453
+ // carries them. Leaving them out of the form dropped them on the next
1454
+ // save: editing a bundled card's name silently discarded all four
1455
+ // intensity example sets, and the card fell back to the flat list.
1456
+ byIntensityText: Object.fromEntries(
1457
+ INTENSITY_LEVELS.map((level) => [
1458
+ level,
1459
+ (card.examplesByIntensity?.[level] ?? []).map((example) => `${example.from} => ${example.to}`).join('\n'),
1460
+ ]),
1461
+ ),
1214
1462
  }
1215
1463
  }
1216
1464
 
@@ -1230,7 +1478,7 @@ window.__ModuleLoader__.load({
1230
1478
  return out
1231
1479
  }
1232
1480
 
1233
- function CardsSettings() {
1481
+ function CardsSettings(props) {
1234
1482
  const shared = useSyncExternalStore(subscribe, getState)
1235
1483
  const [form, setForm] = useState(null)
1236
1484
  const [status, setStatus] = useState(null)
@@ -1238,6 +1486,13 @@ window.__ModuleLoader__.load({
1238
1486
  // Nothing here runs on its own: the drift check is an explicit action so
1239
1487
  // the list stays clean, and only a drifted card grows a restore button.
1240
1488
  const [drift, setDrift] = useState(null)
1489
+ // AI 代填: a rough idea in, a prefilled form out.
1490
+ const [brief, setBrief] = useState('')
1491
+ const [drafting, setDrafting] = useState(false)
1492
+ // The AI box belongs to the NEW-card flow, not to the page: showing it
1493
+ // permanently put two unrelated "make a card" actions side by side and
1494
+ // left the user guessing which one to press. It opens with 新建角色卡.
1495
+ const [aiOpen, setAiOpen] = useState(false)
1241
1496
 
1242
1497
  useEffect(() => {
1243
1498
  void loadCatalog(false).catch(() => {})
@@ -1260,6 +1515,15 @@ window.__ModuleLoader__.load({
1260
1515
  if (form.icon.trim() !== '') payload.icon = form.icon.trim()
1261
1516
  if (form.description.trim() !== '') payload.description = form.description.trim()
1262
1517
  if (form.mode === 'template') payload.template = form.template
1518
+ // Only rungs the user actually filled are sent. An empty textarea means
1519
+ // "no per-intensity set for this level", which makes `examplesFor` fall
1520
+ // back to the flat list — the documented behaviour, not an empty demo.
1521
+ const byIntensity = {}
1522
+ for (const level of INTENSITY_LEVELS) {
1523
+ const parsed = parseExamples(form.byIntensityText?.[level])
1524
+ if (parsed.length > 0) byIntensity[level] = parsed
1525
+ }
1526
+ if (Object.keys(byIntensity).length > 0) payload.examplesByIntensity = byIntensity
1263
1527
  saveCard(payload).then(
1264
1528
  () => {
1265
1529
  setStatus({ kind: 'ok', text: t('settings.saved') })
@@ -1271,7 +1535,11 @@ window.__ModuleLoader__.load({
1271
1535
  }
1272
1536
 
1273
1537
  const remove = (card) => {
1274
- if (!window.confirm(t('settings.deleteConfirm'))) return
1538
+ // Deleting an edited card is not the same act as deleting a card the
1539
+ // user wrote from scratch: it falls back to the bundled version rather
1540
+ // than removing the character entirely, and the wording has to say so.
1541
+ const confirmKey = card.origin === 'edited' ? 'settings.deleteEditedConfirm' : 'settings.deleteConfirm'
1542
+ if (!window.confirm(t(confirmKey))) return
1275
1543
  deleteCard(card.id).then(
1276
1544
  () => {
1277
1545
  setStatus({ kind: 'ok', text: t('settings.saved') })
@@ -1297,6 +1565,59 @@ window.__ModuleLoader__.load({
1297
1565
  )
1298
1566
  }
1299
1567
 
1568
+ /**
1569
+ * Ask the host to write a card from the user's rough idea, then drop the
1570
+ * result into the form.
1571
+ *
1572
+ * The generated card is NEVER saved here. It only prefills the form, so
1573
+ * the user reviews the wording, the examples and the id before anything
1574
+ * reaches the card directory — the same standard the rewrite panel is
1575
+ * held to. A generation the user dislikes costs one reload.
1576
+ */
1577
+ const generate = () => {
1578
+ const text = brief.trim()
1579
+ if (text === '') {
1580
+ setStatus({ kind: 'error', text: t('settings.ai.needBrief') })
1581
+ return
1582
+ }
1583
+ setDrafting(true)
1584
+ setStatus(null)
1585
+ draftCard({ brief: text, sessionId: typeof props?.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : undefined }).then(
1586
+ (value) => {
1587
+ setDrafting(false)
1588
+ setForm(toForm(value?.card ?? {}))
1589
+ setStatus({ kind: 'ok', text: t('settings.ai.hint') })
1590
+ },
1591
+ (error) => {
1592
+ setDrafting(false)
1593
+ setStatus({ kind: 'error', text: t('settings.ai.failed', { message: error?.message ?? t('error.internal') }) })
1594
+ },
1595
+ )
1596
+ }
1597
+
1598
+ /**
1599
+ * Start a new card: open a blank form AND offer the AI box beside it.
1600
+ *
1601
+ * Both live in the same flow on purpose. A blank form and an AI box
1602
+ * presented as two equal toolbar buttons made the user choose between
1603
+ * them before knowing they were related; opening them together makes
1604
+ * "write it yourself, or describe it and let the AI start you off" one
1605
+ * decision instead of two.
1606
+ */
1607
+ const startNew = () => {
1608
+ setForm({ ...BLANK_CARD })
1609
+ setBrief('')
1610
+ setAiOpen(true)
1611
+ setStatus(null)
1612
+ }
1613
+
1614
+ /** Close the new-card flow entirely (form and AI box together). */
1615
+ const cancelForm = () => {
1616
+ setForm(null)
1617
+ setAiOpen(false)
1618
+ setBrief('')
1619
+ }
1620
+
1300
1621
  /** Ask the host which user cards differ from their bundled counterpart. */
1301
1622
  const check = () => {
1302
1623
  setDrift(undefined)
@@ -1321,7 +1642,7 @@ window.__ModuleLoader__.load({
1321
1642
  h(
1322
1643
  'div',
1323
1644
  { className: 'pf-settings-bar' },
1324
- h('button', { type: 'button', className: 'pf-action', onClick: () => { setForm({ ...BLANK_CARD }) ; setStatus(null) } }, t('settings.new')),
1645
+ h('button', { type: 'button', className: 'pf-action', onClick: startNew }, t('settings.new')),
1325
1646
  h(
1326
1647
  'button',
1327
1648
  {
@@ -1348,6 +1669,10 @@ window.__ModuleLoader__.load({
1348
1669
  ),
1349
1670
  h('span', { className: 'pf-small' }, t('settings.count', { n: cards.length })),
1350
1671
  ),
1672
+ // Discoverability, not a second action. The AI box lives inside the
1673
+ // new-card flow; this line is what tells a user it exists, so they do
1674
+ // not have to press a button to find out.
1675
+ h('div', { className: 'pf-small', style: { margin: '0 0 10px' } }, t('settings.ai.tip')),
1351
1676
  // The check result, stated once above the list.
1352
1677
  drift === null
1353
1678
  ? h('div', { className: 'pf-small', style: { marginBottom: '10px' } }, t('settings.check.desc'))
@@ -1381,6 +1706,49 @@ window.__ModuleLoader__.load({
1381
1706
  ? h(
1382
1707
  'div',
1383
1708
  { className: 'pf-form', style: { margin: '12px 0' } },
1709
+ // The AI box, first thing in the new-card flow: describe it, or
1710
+ // fill the fields below by hand. It is collapsible so a user who
1711
+ // came to type does not have to scroll past it.
1712
+ h(
1713
+ 'div',
1714
+ { className: 'pf-ai' },
1715
+ h(
1716
+ 'button',
1717
+ {
1718
+ type: 'button',
1719
+ className: 'pf-ai-head',
1720
+ 'aria-expanded': aiOpen ? 'true' : 'false',
1721
+ onClick: () => setAiOpen((value) => !value),
1722
+ },
1723
+ h('span', { 'aria-hidden': true }, aiOpen ? '▾' : '▸'),
1724
+ h('span', null, t('settings.ai')),
1725
+ ),
1726
+ aiOpen
1727
+ ? h(
1728
+ 'div',
1729
+ { style: { display: 'grid', gap: '8px', padding: '8px 10px 10px' } },
1730
+ h('textarea', {
1731
+ value: brief,
1732
+ placeholder: t('settings.ai.briefPlaceholder'),
1733
+ disabled: drafting,
1734
+ onChange: (event) => setBrief(event.target.value),
1735
+ 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' },
1736
+ }),
1737
+ h('div', { className: 'pf-small' }, t('settings.ai.desc')),
1738
+ h(
1739
+ 'div',
1740
+ { className: 'pf-actions' },
1741
+ h('button', {
1742
+ type: 'button',
1743
+ className: 'pf-action',
1744
+ disabled: drafting || brief.trim() === '',
1745
+ onClick: generate,
1746
+ }, drafting ? t('settings.ai.busy') : t('settings.ai.generate')),
1747
+ h('span', { className: 'pf-small' }, shared.draftModelLabel),
1748
+ ),
1749
+ )
1750
+ : null,
1751
+ ),
1384
1752
  h(
1385
1753
  'div',
1386
1754
  { className: 'pf-grid2' },
@@ -1453,11 +1821,33 @@ window.__ModuleLoader__.load({
1453
1821
  h('div', { className: 'pf-small' }, t('settings.hint.examples')),
1454
1822
  )
1455
1823
  : null,
1824
+ // The four intensity sets. These are part of the card, so the form
1825
+ // has to show them: an editable card whose strongest feature is
1826
+ // invisible is a card the user cannot trust an edit to preserve.
1827
+ form.mode === 'llm'
1828
+ ? h(
1829
+ 'div',
1830
+ { className: 'pf-field' },
1831
+ h('label', null, t('settings.field.byIntensity')),
1832
+ ...INTENSITY_LEVELS.map((level) => h(
1833
+ 'div',
1834
+ { key: level, style: { display: 'grid', gap: '2px', marginBottom: '6px' } },
1835
+ h('div', { className: 'pf-small' }, t(`control.intensity.${level}`)),
1836
+ h('textarea', {
1837
+ value: form.byIntensityText?.[level] ?? '',
1838
+ placeholder: t('settings.field.byIntensity.empty'),
1839
+ onChange: (event) => set({ byIntensityText: { ...(form.byIntensityText ?? {}), [level]: event.target.value } }),
1840
+ style: { minHeight: '60px' },
1841
+ }),
1842
+ )),
1843
+ h('div', { className: 'pf-small' }, t('settings.hint.byIntensity')),
1844
+ )
1845
+ : null,
1456
1846
  h(
1457
1847
  'div',
1458
1848
  { className: 'pf-actions' },
1459
1849
  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')),
1850
+ h('button', { type: 'button', className: 'pf-action', onClick: cancelForm }, t('settings.cancel')),
1461
1851
  ),
1462
1852
  )
1463
1853
  : null,
@@ -1475,7 +1865,7 @@ window.__ModuleLoader__.load({
1475
1865
  'span',
1476
1866
  { className: 'pf-card-name' },
1477
1867
  card.name,
1478
- h('span', { className: 'pf-tag' }, card.builtin === true ? t('settings.builtin') : t('settings.user')),
1868
+ h('span', { className: 'pf-tag' }, t(`settings.origin.${card.origin ?? 'custom'}`)),
1479
1869
  h('span', { className: 'pf-tag' }, card.mode === 'template' ? 'template' : 'llm'),
1480
1870
  h('span', { className: 'pf-tag' }, t(`control.intensity.${card.intensity}`)),
1481
1871
  ),
@@ -1491,7 +1881,13 @@ window.__ModuleLoader__.load({
1491
1881
  onClick: () => reset(card),
1492
1882
  }, t('settings.reset'))
1493
1883
  : null,
1494
- h('button', { type: 'button', className: 'pf-action', onClick: () => remove(card) }, t('settings.delete')),
1884
+ // Delete is offered only where it can actually take effect. A card
1885
+ // whose content still matches the bundle has no user-owned file:
1886
+ // removing it just clears a copied seed that the next start writes
1887
+ // back, so the button would report success and change nothing.
1888
+ card.origin === 'builtin'
1889
+ ? null
1890
+ : h('button', { type: 'button', className: 'pf-action', onClick: () => remove(card) }, t('settings.delete')),
1495
1891
  )),
1496
1892
  ),
1497
1893
  )