dsh-soul 0.7.0 → 0.7.2

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.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -19
  3. package/README_EN.md +36 -19
  4. package/assets/personas/custom-placeholder.webp +0 -0
  5. package/assets/personas/placeholder.svg +16 -0
  6. package/assets/personas//344/270/245/346/240/274/344/273/243/347/240/201/345/256/241/351/230/205/350/200/205.webp +0 -0
  7. package/assets/personas//345/244/264/350/204/221/351/243/216/346/232/264/344/274/231/344/274/264.webp +0 -0
  8. package/assets/personas//346/225/231/345/255/246/345/236/213/350/256/262/350/247/243/350/200/205.webp +0 -0
  9. package/assets/personas//346/236/201/347/256/200/344/270/273/344/271/211/350/200/205.webp +0 -0
  10. package/assets/personas//350/207/252/351/251/261/345/236/213/345/215/217/344/275/234/350/200/205.webp +0 -0
  11. package/assets/personas//350/213/217/346/240/274/346/213/211/345/272/225/345/274/217/346/217/220/351/227/256/350/200/205.webp +0 -0
  12. package/assets/personas//350/265/204/346/267/261/346/236/266/346/236/204/345/270/210.webp +0 -0
  13. package/client/index.mjs +1169 -214
  14. package/index.mjs +732 -85
  15. package/lib/config.mjs +42 -6
  16. package/lib/injection.mjs +2 -2
  17. package/lib/personas.mjs +62 -25
  18. package/lib/store.mjs +125 -0
  19. package/locale/en.json +6 -0
  20. package/locale/zh.json +6 -0
  21. package/package.json +13 -2
  22. package/scripts/lib/skip-report.mjs +73 -0
  23. package/scripts/verify-client.mjs +1319 -0
  24. package/scripts/verify-compat.mjs +58 -9
  25. package/scripts/verify-config.mjs +2116 -44
  26. package/scripts/verify-e2e-prompt.mjs +627 -0
  27. package/scripts/verify-live-prompt.mjs +445 -0
  28. package/scripts/verify-nav-icon.mjs +35 -7
  29. package/scripts/verify-store.mjs +310 -0
  30. package/scripts/verify-trail.mjs +21 -8
package/client/index.mjs CHANGED
@@ -3,12 +3,12 @@
3
3
  // 在设置页面添加「个性化」栏目:
4
4
  // - 启用/禁用个性化设置
5
5
  // - 「关于你」(昵称/职业/介绍)、回复风格和语调、特质、输出语言、自定义指令
6
- // - 人设预设:6 个内置人设(随插件提供、不可删除,行尾标「内置」)+ 保存当前为预设、一键使用(★ 标记当前匹配项)、删除
6
+ // - 人设预设:7 个内置人设(随插件提供、不可删除,行尾标「内置」)+ 保存当前为预设、一键使用(★ 标记当前匹配项)、删除
7
7
  // - Agent 工具:set_persona 确认模式开关
8
8
  // - 输入框光轨:Agent 回复中时输入框边框的流光动效(颜色 / 速度 / 粗细 + 实时示例)
9
9
  // - dirty 检测(无改动禁用保存)、统一 toast 提示
10
10
  // - 提示词预览(只读):无未保存编辑时显示「当前生效提示词」,有编辑时改为编译草稿
11
- // —— 展示保存后真正会注入的内容,并在保存/重置/应用预设后自动刷新
11
+ // —— 展示保存后真正会生效的内容,并在保存/重置/应用预设后自动刷新
12
12
  //
13
13
  // 光轨另注册到 conversation.input.overlay 槽位(输入框卡片内部),
14
14
  // 运行时按会话 running 状态在卡片上挂载/隐藏 SVG 环,见 SoulTrail。
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
48
48
  // -------------------------------------------------------------------------
49
49
 
50
50
  const NS = 'soul'
51
- const VERSION = '0.7.0'
51
+ const VERSION = '0.7.2'
52
52
  const INITIAL = {
53
53
  enabled: true,
54
54
  nickname: '',
@@ -71,11 +71,10 @@ window.__ModuleLoader__.load({
71
71
  // 人设预设库与当前匹配项(GET /api/soul/personas 带回)
72
72
  personas: null,
73
73
  activePersona: null,
74
- loading: false,
74
+ // 预设列表请求失败的原因(成功时清空)。用于把「加载中」与「加载失败」区分开
75
+ personasError: null,
75
76
  saving: false,
76
- error: null,
77
- // 最近一次保存实际变化的字段名数组(来自写路径的 changed)
78
- lastChanged: null
77
+ error: null
79
78
  }
80
79
 
81
80
  // 第三方插件不能 require('@deepseek-ai/dsh-client-runtime/client')——
@@ -91,13 +90,43 @@ window.__ModuleLoader__.load({
91
90
  update: (mutator) => {
92
91
  const next = { ...state }
93
92
  mutator(next)
94
- state = next
95
- notify()
93
+ // 值全等时不换引用、也不通知:设置页每 2s 轮询一次宿主配置,绝大多数轮次
94
+ // 什么都没变,此前却每次都让订阅方(设置页 / 快捷开关 / 光轨)白重渲染一次。
95
+ // 取两侧键集的并集比较:只遍历 next 会漏掉「mutator 删了某个键」的情形,
96
+ // 那会变成静默不更新 —— 比多通知一次严重得多。
97
+ for (const key of new Set([...Object.keys(state), ...Object.keys(next)])) {
98
+ if (!Object.is(next[key], state[key])) {
99
+ state = next
100
+ notify()
101
+ return
102
+ }
103
+ }
96
104
  },
97
105
  set: (next) => { state = next; notify() },
98
106
  }
99
107
  }
100
108
 
109
+ // 一层浅比较:用于判断服务端新解析出的对象是否与已存的等价。
110
+ // 预设条目本身是扁平对象(人格字段 + updatedAt / builtin),所以一层足够。
111
+ const shallowEqual = (a, b) => {
112
+ if (a === b) return true
113
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false
114
+ const ka = Object.keys(a)
115
+ const kb = Object.keys(b)
116
+ if (ka.length !== kb.length) return false
117
+ return ka.every((key) => Object.is(a[key], b[key]))
118
+ }
119
+
120
+ // 预设库是「库 → 条目」两层结构,需要逐条目比。
121
+ const samePersonas = (a, b) => {
122
+ if (a === b) return true
123
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false
124
+ const ka = Object.keys(a)
125
+ const kb = Object.keys(b)
126
+ if (ka.length !== kb.length) return false
127
+ return ka.every((key) => shallowEqual(a[key], b[key]))
128
+ }
129
+
101
130
  function hostBase() {
102
131
  const origin = globalThis.location?.origin
103
132
  return origin !== void 0 && origin !== 'null' ? origin : 'http://dsh.internal'
@@ -338,6 +367,17 @@ window.__ModuleLoader__.load({
338
367
  this.fetcher = fetcher
339
368
  this.store = createSnapshotStore(INITIAL)
340
369
  this.disposed = false
370
+ // 外部轮询的暂停标记(由设置页在「有未保存编辑」时置位)。
371
+ //
372
+ // 为什么放在 controller 上而不是组件 state:轮询已从设置页组件**上移到
373
+ // apply()**(见那里的注释),装配层读不到组件的 dirty/saving。而「编辑中
374
+ // 不覆盖用户输入」这条语义仍必须保留,所以在共享的 controller 上开一个口。
375
+ this.paused = false
376
+ }
377
+
378
+ // 是否允许外部轮询写入 store
379
+ canRefresh() {
380
+ return !this.disposed && !this.paused
341
381
  }
342
382
 
343
383
  // 把服务端返回的完整配置映射进 store
@@ -362,18 +402,25 @@ window.__ModuleLoader__.load({
362
402
 
363
403
  async loadConfig() {
364
404
  if (this.disposed) return
365
- this.store.update(s => { s.loading = true; s.error = null })
405
+ // 开始读取就清掉上一次的错误;此前还写了一个 s.loading,但全客户端零处读取
406
+ // (设置页首帧直接显示 INITIAL 默认值),属于只写不读的死状态,已删。
407
+ this.store.update(s => { s.error = null })
366
408
 
367
409
  try {
368
410
  const payload = await this.postJSON('/api/soul/config', null, { method: 'GET' })
369
411
  this.store.update(s => {
370
412
  this.applyConfig(s, payload.config)
371
- s.loading = false
413
+ // 两类问题分开上报、合到同一条提示里:
414
+ // configError —— 磁盘配置读不出来(回退默认值,写入被拒)
415
+ // deliveryWarning —— 配置送不到活动会话(agents 服务不可用等)
416
+ // 两者表面症状都是「改了人设不生效」,所以都要让用户看得见。
417
+ s.error = typeof payload.configError === 'string'
418
+ ? payload.configError
419
+ : (typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null)
372
420
  })
373
421
  } catch (error) {
374
422
  if (this.disposed) return
375
423
  this.store.update(s => {
376
- s.loading = false
377
424
  s.error = messageOf(error)
378
425
  })
379
426
  }
@@ -387,7 +434,8 @@ window.__ModuleLoader__.load({
387
434
  const payload = await this.postJSON('/api/soul/config', config)
388
435
  this.store.update(s => {
389
436
  this.applyConfig(s, payload.config)
390
- s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
437
+ // 保存成功也可能带着送达警告(配置写下了但送不到会话)
438
+ s.error = typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null
391
439
  s.saving = false
392
440
  })
393
441
  return payload
@@ -408,7 +456,7 @@ window.__ModuleLoader__.load({
408
456
  const payload = await this.postJSON('/api/soul/config/reset')
409
457
  this.store.update(s => {
410
458
  this.applyConfig(s, payload.config)
411
- s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
459
+ s.error = typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null
412
460
  s.saving = false
413
461
  })
414
462
  return payload
@@ -426,6 +474,32 @@ window.__ModuleLoader__.load({
426
474
  return await this.postJSON('/api/soul/personas', null, { method: 'GET' })
427
475
  }
428
476
 
477
+ // 拉取预设列表并写入 store。
478
+ //
479
+ // 从 SoulSettings 组件**下沉**到这里:全局轮询(apply 层)与设置页都要用它,
480
+ // 逻辑留在组件里会导致装配层无法调用、只能重写一遍 ⇒ 两套写 store 的代码。
481
+ async loadPersonas() {
482
+ if (this.disposed) return
483
+ try {
484
+ const payload = await this.fetchPersonas()
485
+ this.store.update((s) => {
486
+ // 内容相同就保留旧引用:每 2s 轮询都会重新解析出**新对象**,若直接赋值,
487
+ // store 的浅比较永远判为「变了」⇒ 去重失效,整页仍每 2s 白重渲染。
488
+ s.personas = samePersonas(s.personas, payload.personas) ? s.personas : payload.personas
489
+ s.activePersona = payload.activeName
490
+ // 拉取成功:清掉上一次的失败痕迹(否则失败后即便恢复,提示会一直挂着)
491
+ s.personasError = null
492
+ })
493
+ } catch (err) {
494
+ // 预设列表加载失败不打断主配置界面 —— 但必须**留下痕迹**:
495
+ // 否则 personas 永远是 null,摘要与面板一直显示「加载中...」,
496
+ // 与「真的还没有预设」无从区分,也没有任何失败提示。
497
+ this.store.update((s) => {
498
+ s.personasError = messageOf(err)
499
+ })
500
+ }
501
+ }
502
+
429
503
  async savePersona(name) {
430
504
  return await this.postJSON('/api/soul/personas/save', { name })
431
505
  }
@@ -435,7 +509,9 @@ window.__ModuleLoader__.load({
435
509
  if (!this.disposed) {
436
510
  this.store.update(s => {
437
511
  this.applyConfig(s, payload.config)
438
- s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
512
+ // 清掉上一轮的失败提示:该端点不返回 deliveryWarning,此前一旦上一轮
513
+ // 保存失败过,切预设成功后红色错误条会一直挂着。
514
+ s.error = null
439
515
  })
440
516
  }
441
517
  return payload
@@ -468,7 +544,7 @@ window.__ModuleLoader__.load({
468
544
  }
469
545
 
470
546
  // 预览「未保存的编辑」编译出的提示词:POST 草稿字段,服务端以已保存配置为底
471
- // 覆盖后编译。不落盘、不注入 —— 返回的 invalid 列出未通过校验、因而未计入
547
+ // 覆盖后编译。不落盘、不影响生效提示词 —— 返回的 invalid 列出未通过校验、因而未计入
472
548
  // 预览的字段名,供界面如实提示,避免用户以为改动已生效。
473
549
  async previewPrompt(draft) {
474
550
  const payload = await this.postJSON('/api/soul/prompt/preview', draft)
@@ -485,74 +561,272 @@ window.__ModuleLoader__.load({
485
561
  // -------------------------------------------------------------------------
486
562
 
487
563
  const css = [
488
- '.soul-section{margin:24px 0;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}',
489
- '.soul-section h3{margin:0 0 16px 0;font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
490
- '.soul-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}',
491
- '.soul-version{flex:0 0 auto;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400;line-height:1.4;white-space:nowrap}',
492
- '.soul-field{margin-bottom:16px;width:100%}',
493
- '.soul-field label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
494
- '.soul-field select,.soul-field textarea,.soul-field input[type=text]{width:100%;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;box-sizing:border-box}',
564
+ // —— 与宿主设置页对齐的栏目容器 ——
565
+ // 宿主其余栏目(models / plugins / plugin-inventory)都不用外框:内容直接落在
566
+ // 设置页 options 容器自带的 `padding:0 24px 24px` 里,靠 `max-width` 限制行长,
567
+ // 分组则用「卡片」表达(.5px 边框 + settings-card-* + radius-xl)。
568
+ // 我们此前是 `.soul-section{margin:24px 0;padding:20px;border:1px;radius:8px}`
569
+ // —— 等于在宿主内边距里又套了一层盒子,是观感不统一最直接的原因,故整层去掉。
570
+ '.soul-section{display:flex;flex-direction:column;gap:12px;width:100%;max-width:760px;color:var(--dsw-alias-label-primary)}',
571
+ // 标题:与宿主栏目标题同规格(16px / 500 / lh24,不是 600)
572
+ '.soul-section h3{margin:0;font-size:16px;font-weight:500;line-height:24px;color:var(--dsw-alias-label-primary)}',
573
+ '.soul-title-row{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}',
574
+ '.soul-version{flex:0 0 auto;color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;white-space:nowrap}',
575
+ // 字段:宿主用 12px / 500 / lh18 的字段标签 + 8px 间距
576
+ '.soul-field{margin-bottom:14px;width:100%}',
577
+ '.soul-field label{display:block;margin-bottom:8px;font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-secondary)}',
578
+ // 与上面的 label 同形,但用于「没有对应控件」的分组标题(如光轨示例预览):
579
+ // 那种位置写 <label> 会是一个指向空的标签,语义错误
580
+ '.soul-field-label{display:block;margin-bottom:8px;font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-secondary)}',
581
+ // 控件:边框换宿主卡片描边色、圆角用 sm(8px),与宿主输入控件一致
582
+ '.soul-field select,.soul-field textarea,.soul-field input[type=text]{width:100%;padding:8px 12px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;box-sizing:border-box}',
495
583
  '.soul-field select option{color:var(--dsw-alias-label-primary)}',
496
584
  '.soul-section{color-scheme:light}',
497
585
  'body[data-ds-dark-theme] .soul-section{color-scheme:dark}',
498
586
  '.soul-field select{color-scheme:light}',
499
587
  'body[data-ds-dark-theme] .soul-field select{color-scheme:dark}',
500
- '.soul-field textarea{min-height:100px;resize:vertical}',
501
- '.soul-field textarea.soul-textarea-sm{min-height:60px}',
502
- '.soul-group-title{margin:20px 0 12px 0;padding-bottom:8px;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
503
- '.soul-accordion{margin:12px 0;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;overflow:visible;background:var(--dsw-alias-bg-layer-1)}',
588
+ '.soul-field textarea{min-height:120px;resize:vertical}',
589
+ '.soul-field textarea.soul-textarea-sm{min-height:72px}',
590
+ // 组标题:宿主用 15px/600 的小标题,这里对齐并去掉下边框(宿主分组靠卡片分隔,不靠横线)
591
+ '.soul-group-title{margin:4px 0 0 0;font-size:15px;font-weight:600;line-height:22px;color:var(--dsw-alias-label-primary)}',
592
+ // 折叠分组=宿主卡片:.5px 描边 + card-fill + radius-xl + overflow hidden
593
+ // 刻意不用 overflow:hidden 裁子元素圆角:那会把「向上弹出」的 .soul-hint-tip
594
+ // 在跨出分组上边界时切掉顶部(实测复现:Agent 工具分组的提示是该分组第一个
595
+ // 元素,上方只有 14px 内边距,而提示高约 60px,必然越界被裁)。
596
+ // 改为给触发器补顶圆角,让 hover 背景自己贴合 —— 视觉等价且不裁剪。
597
+ '.soul-accordion{margin:0;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}',
504
598
  '.soul-accordion-open{position:relative;z-index:20}',
505
- // 展开时标题行下沿改直角:与下方内容区形成一条清晰的水平交界,
506
- // 避免"标题与内容同为一片底色、分不清哪块是标题"
507
- '.soul-accordion-open .soul-accordion-trigger{border-radius:5px 5px 0 0}',
508
- '.soul-accordion-trigger{display:flex;align-items:center;width:100%;min-height:44px;gap:10px;padding:10px 12px;border:0;border-radius:5px;background:var(--dsw-alias-interactive-bg-active,rgba(127,127,127,0.12));color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;transition:background-color .15s ease}',
509
- '.soul-accordion-trigger:hover{background:var(--dsw-alias-interactive-bg-hover-accent,rgba(127,127,127,0.2))}',
510
- '.soul-accordion-title{flex:0 0 auto;font-size:13px;font-weight:600}',
511
- '.soul-accordion-summary{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400}',
599
+ // 顶圆角补偿:容器不再裁剪,触发器的 hover 背景需自己贴合外层圆角
600
+ // (半径为外层圆角减去 0.5px 边框)。展开时底边保持直角,与 body 的分隔线相接。
601
+ '.soul-accordion-trigger{display:flex;align-items:center;width:100%;min-height:44px;gap:10px;padding:10px 14px;border:0;border-radius:calc(var(--dsw-radius-xl) - .5px) calc(var(--dsw-radius-xl) - .5px) 0 0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;transition:background-color .15s ease}',
602
+ '.soul-accordion-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}',
603
+ '.soul-accordion-title{flex:0 0 auto;font-size:14px;font-weight:500;line-height:22px}',
604
+ '.soul-accordion-summary{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}',
512
605
  '.soul-accordion-icon{flex:0 0 18px;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:1;text-align:center}',
513
- '.soul-accordion-body{padding:12px;border-top:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)}',
606
+ // 收起态只有触发器一个子元素 ⇒ 四角都要圆(展开态由上面的顶圆角规则负责)
607
+ '.soul-accordion:not(.soul-accordion-open) .soul-accordion-trigger{border-radius:calc(var(--dsw-radius-xl) - .5px)}',
608
+ '.soul-accordion-body{padding:14px;border-top:.5px solid var(--dsw-alias-settings-card-stroke);background:transparent}',
514
609
  '.soul-field select:focus,.soul-field textarea:focus,.soul-field input[type=text]:focus{outline:none;border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 25%, transparent)}',
515
- '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:16px}',
516
- '.soul-toggle label{margin:0;font-size:13px;color:var(--dsw-alias-label-primary)}',
517
- '.soul-buttons{display:flex;gap:8px;margin-top:16px}',
518
- '.soul-quick-toggle{position:relative;isolation:isolate;display:inline-flex;align-items:center;gap:6px;min-height:28px;overflow:hidden;padding:4px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1;cursor:pointer;white-space:nowrap}',
610
+ '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:14px}',
611
+ '.soul-toggle label{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
612
+ '.soul-buttons{display:flex;gap:8px;margin-top:2px}',
613
+ '.soul-quick-toggle{position:relative;isolation:isolate;display:inline-flex;align-items:center;gap:6px;min-height:28px;overflow:hidden;padding:4px 10px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}',
519
614
  '.soul-quick-toggle:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
520
615
  '.soul-quick-toggle[data-enabled="true"]{color:var(--dsw-alias-brand-primary)}',
521
- '.soul-quick-toggle[data-enabled="true"]:hover{border-color:#ef4444;color:#ef4444;box-shadow:0 0 0 2px rgba(239,68,68,.18)}',
522
- '.soul-quick-toggle[data-enabled="false"]:hover{border-color:#22c55e;color:#22c55e;box-shadow:0 0 0 2px rgba(34,197,94,.18)}',
523
- '.soul-quick-toggle[data-enabled="true"]:hover::before{background:rgba(239,68,68,.24);animation:soul-toggle-red-sweep .55s ease-out both}',
524
- '.soul-quick-toggle[data-enabled="false"]:hover::before{background:rgba(34,197,94,.24);animation:soul-toggle-green-sweep .55s ease-out both}',
616
+ '.soul-quick-toggle[data-enabled="true"]:hover{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-error-primary) 18%,transparent)}',
617
+ '.soul-quick-toggle[data-enabled="false"]:hover{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-success-primary) 18%,transparent)}',
618
+ '.soul-quick-toggle[data-enabled="true"]:hover::before{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 24%,transparent);animation:soul-toggle-red-sweep .55s ease-out both}',
619
+ '.soul-quick-toggle[data-enabled="false"]:hover::before{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 24%,transparent);animation:soul-toggle-green-sweep .55s ease-out both}',
525
620
  '.soul-quick-toggle::before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;transform:translateX(100%)}',
526
621
  '.soul-quick-toggle > *{position:relative;z-index:1}',
527
622
  '.soul-quick-toggle-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}',
528
- '.soul-quick-toggle[data-enabled="true"] .soul-quick-toggle-dot{background:var(--dsw-static-green-500,#22c55e);animation:soul-status-pulse 1.8s ease-in-out infinite}',
623
+ '.soul-quick-toggle[data-enabled="true"] .soul-quick-toggle-dot{background:var(--dsw-alias-state-success-primary);animation:soul-status-pulse 1.8s ease-in-out infinite}',
624
+ // 保存失败:小圆点转红并停止脉冲(按钮文案不变,故必须靠颜色 + title/aria-label 反馈)
625
+ // 注意:--dsw-alias-label-error **在宿主主题里不存在**(实测 var() 回退成继承色=黑),
626
+ // 宿主表达错误色统一用 --dsw-alias-state-error-primary,这里一并纠正。
627
+ '.soul-quick-toggle[data-failed="true"]{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}',
628
+ '.soul-quick-toggle[data-failed="true"] .soul-quick-toggle-dot{background:var(--dsw-alias-state-error-primary);animation:none}',
529
629
  '.soul-quick-toggle:disabled{cursor:wait;opacity:.6}',
530
630
  '@keyframes soul-toggle-red-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
531
631
  '@keyframes soul-toggle-green-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
532
- '@keyframes soul-status-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-static-green-500,#22c55e) 35%,transparent);opacity:.75}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--dsw-static-green-500,#22c55e) 0%,transparent);opacity:1}}',
632
+ '@keyframes soul-status-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-alias-state-success-primary) 35%,transparent);opacity:.75}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--dsw-alias-state-success-primary) 0%,transparent);opacity:1}}',
533
633
  '@media (prefers-reduced-motion:reduce){.soul-quick-toggle[data-enabled="true"] .soul-quick-toggle-dot{animation:none}.soul-quick-toggle[data-enabled="true"]:hover::before,.soul-quick-toggle[data-enabled="false"]:hover::before{animation:none;transform:none}}',
534
- '.soul-error{margin-top:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-label-error);border-radius:6px;color:var(--dsw-alias-label-error);font-size:12px;width:100%;box-sizing:border-box}',
634
+ // 快捷开关里追加的人设名(B):同样单行省略,避免把底栏撑宽
635
+ '.soul-quick-toggle-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:9em;opacity:.85}',
636
+ // 错误提示:颜色改用宿主真实存在的 state-error-primary(原先引用不存在的 token ⇒ 渲染成黑色)
637
+ '.soul-error{margin-top:8px;padding:10px 12px;background:var(--dsw-alias-interactive-bg-hover-danger,rgba(236,19,19,.05));border:.5px solid var(--dsw-alias-state-error-primary);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;width:100%;box-sizing:border-box}',
535
638
  '.soul-hint{position:relative;display:inline-flex;align-items:center;margin-left:6px;color:var(--dsw-alias-label-secondary);cursor:help;vertical-align:middle}',
536
639
  '.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
537
- '.soul-hint-tip{position:absolute;bottom:calc(100% + 8px);left:0;padding:8px 10px;background:#ffffff;border:1px solid rgba(0,0,0,0.1);border-radius:6px;font-size:12px;font-weight:400;line-height:1.5;color:rgba(0,0,0,0.85);white-space:normal;width:max-content;max-width:260px;text-align:left;opacity:0;visibility:hidden;transition:opacity .15s ease;pointer-events:none;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.15)}',
640
+ '.soul-hint-tip{position:absolute;bottom:calc(100% + 8px);left:0;padding:8px 10px;background:var(--dsw-alias-bg-module-platform);border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);font-size:12px;font-weight:400;line-height:1.5;color:var(--dsw-alias-label-primary);white-space:normal;width:max-content;max-width:260px;text-align:left;opacity:0;visibility:hidden;transition:opacity .15s ease;pointer-events:none;z-index:9999;box-shadow:var(--dsw-shadow-lv3,0 4px 12px rgba(0,0,0,0.15))}',
538
641
  '.soul-hint:hover .soul-hint-tip{opacity:1;visibility:visible}',
539
- '.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
540
- '.soul-dirty{color:#d46b08}',
541
- '.soul-persona-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px dashed var(--dsw-alias-border-l2);font-size:12px}',
542
- '.soul-persona-name{color:var(--dsw-alias-label-primary);font-weight:500;white-space:nowrap}',
543
- '.soul-persona-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
544
- '.soul-persona-actions{display:flex;gap:10px;white-space:nowrap;align-items:center}',
545
- '.soul-persona-badge{font-size:11px;line-height:16px;padding:0 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
546
- '.soul-persona-save{display:flex;gap:8px}',
547
- '.soul-persona-save input[type=text]{flex:1}',
548
- '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
549
- '.soul-prompt-link:hover{color:var(--dsw-alias-label-primary)}',
550
- '.soul-persona-danger{color:var(--dsw-alias-label-error)}',
551
- '.soul-prompt-pre{margin:8px 0 0 0;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto;font-family:Consolas,Menlo,monospace;text-align:left;width:100%;box-sizing:border-box}',
552
- '.soul-toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;z-index:10000;box-shadow:0 2px 8px rgba(0,0,0,0.15);pointer-events:none;text-align:center;min-width:auto;white-space:nowrap}',
553
- '.soul-toast-success{background:#f6ffed;border:1px solid #b7eb8f;color:#52c41a}',
554
- '.soul-toast-error{background:#fff1f0;border:1px solid #ffa39e;color:#f5222d}',
555
- '@keyframes fadeInOut{0%{opacity:0;transform:translate(-50%,-50%) scale(0.9)}15%{opacity:1;transform:translate(-50%,-50%) scale(1)}85%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(0.9)}}',
642
+ // 键盘聚焦同样要能拿到说明:只有 :hover 的话,键盘 / 读屏用户永远看不到提示
643
+ '.soul-hint:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;border-radius:3px}',
644
+ '.soul-hint:focus-visible .soul-hint-tip{opacity:1;visibility:visible}',
645
+ '.soul-status{margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
646
+ '.soul-dirty{color:var(--dsw-alias-state-warn-primary)}',
647
+ // —— 人设预设:竖版卡牌 + 堆叠轮播 ——
648
+ // 方案 B:插画是卡面主视觉(方形满铺),名称与摘要压在图上由渐变承接,
649
+ // 效果与按钮在下方的白色信息区。卡牌竖版后「一次只见一张」的代价更大,
650
+ // 因此保留位置指示、圆点与左右翻页(见 .soul-persona-nav)。
651
+ '.soul-persona-carousel{position:relative;display:flex;flex-direction:column;gap:12px}',
652
+ // 堆叠容器:卡牌绝对定位其中,容器高度固定 ⇒ 切换时不跳动。
653
+ // 竖版卡牌高度 = 插画 260(等于卡宽的正方形)+ 信息区约 92 + 边框 ⇒ 取 372。
654
+ // 左右留白不需要 padding:卡片以 margin:auto 居中,±92px 的两张展开后总宽约 430px,
655
+ // 远小于容器宽度(设置页 max-width 760px),不会溢出。
656
+ '.soul-persona-stack{position:relative;min-height:372px}',
657
+ // 卡牌:固定宽度居中(left/right:0 + margin auto),靠 transform 做叠层位移与缩放。
658
+ // top/bottom:0 让三张卡等高 —— 内容长短不同的卡片高度一致,堆叠才整齐。
659
+ //
660
+ // ⚠️ 卡片**不能**有 overflow:hidden:CSS 规范规定 overflow != visible 会强制
661
+ // transform-style: flat,3D 翻转会被压平。裁切职责已下沉到 .soul-persona-face
662
+ // (它带 overflow:hidden + border-radius:inherit,效果等价)。
663
+ '.soul-persona-card{position:absolute;top:0;bottom:0;left:0;right:0;width:260px;margin:0 auto;display:flex;flex-direction:column;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-settings-card-stroke);background:var(--dsw-alias-settings-card-fill);box-shadow:var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.08));box-sizing:border-box;font-size:12px;line-height:18px;transition:transform .3s cubic-bezier(.22,.61,.36,1),opacity .3s ease;transform-origin:center top;will-change:transform;perspective:1000px}',
664
+ // —— 卡牌的两面(正面 = 卡面,背面 = 详情):真 3D 翻转 ——
665
+ //
666
+ // 三层职责分离(这是能做真 3D 而不伤叠层几何的关键):
667
+ // .soul-persona-card 叠层位置(translateX/Y/scale,内联)
668
+ // .soul-persona-flipper 翻到哪一面(rotateY 0 ↔ 180)
669
+ // .soul-persona-face-* 两个面各自的内容
670
+ // 若把 rotateY 写进卡片,就要和叠层偏移合成同一个 transform 字符串 ⇒
671
+ // 偏移几何变脆。
672
+ //
673
+ // ⚠️ perspective 已并入上面的 .soul-persona-card 规则(单条规则,便于断言核对):
674
+ // 它必须挂在**翻转元素的父级**(卡片)上才生效。
675
+ '.soul-persona-card{perspective:1000px}',
676
+ '.soul-persona-flipper{position:absolute;inset:0;transform-style:preserve-3d;transition:transform .5s cubic-bezier(.2,.7,.3,1);border-radius:inherit}',
677
+ '.soul-persona-card[data-flipped="true"] .soul-persona-flipper{transform:rotateY(180deg)}',
678
+ // 两个面:各自裁圆角 + 隐藏背面,构成标准 3D 翻转
679
+ '.soul-persona-face{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;border-radius:inherit;backface-visibility:hidden;-webkit-backface-visibility:hidden;transition:opacity .18s ease}',
680
+ // 整面可点(任意处点击翻到卡背):cursor 提示可点性
681
+ '.soul-persona-face-front{opacity:1}',
682
+ // 当前卡整面可点翻背(cursor 提示);叠层侧边卡的点击是翻页,不提示翻面
683
+ '.soul-persona-card[data-current="true"]:not([data-flipped="true"]) .soul-persona-face-front{cursor:pointer}',
684
+ // 背面预先转到 180°,随 flipper 一起转回正面
685
+ // 卡背整面可点返回(与正面「整面点击翻到背面」对称):cursor 提示可点。
686
+ // 全文块单独覆盖为 text —— 那里是可选中的文字,光标形态要与之相符。
687
+ '.soul-persona-face-back{gap:6px;padding:10px;box-sizing:border-box;transform:rotateY(180deg);opacity:1;cursor:pointer}',
688
+ // 未翻面时背面整层不可点、也不被读屏念到(它虽被 backface 隐藏,但仍在树里)
689
+ '.soul-persona-card:not([data-flipped="true"]) .soul-persona-face-back{pointer-events:none}',
690
+ // 尊重「减少动态效果」:不做 3D 位移,直接切换
691
+ '@media (prefers-reduced-motion:reduce){.soul-persona-flipper{transition:none}}',
692
+ // 背面头部:名称 + 内置徽标。
693
+ // 名称用 flex:0 1 auto(**不是** flex:1 1 auto):后者会把它撑满整行,
694
+ // 于是徽标与名称**重叠在同一位置**(实测:名称 [44,272]、徽标 [44,77],
695
+ // 视觉上徽标盖住了名称开头,看起来像被截成了「协作者」)。
696
+ // 现在名称按内容取宽、仅在过长时收缩(min-width:0 + ellipsis 兜底),
697
+ // 徽标 flex:none 紧随其后。
698
+ '.soul-persona-back-head{display:flex;align-items:center;gap:6px;flex:none}',
699
+ '.soul-persona-back-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px}',
700
+ // 卡背里的徽标必须**重置定位**:.soul-persona-badge 为压在插画上而设了
701
+ // position:absolute(见该规则),在卡背的 flex 行里它脱离流 ⇒ 与名称重叠
702
+ // (实测:名称 [44,75]、徽标 [44,77],视觉上徽标盖住名称开头)。
703
+ // 这里改回 static 让它正常参与 flex 布局。
704
+ '.soul-persona-back-head .soul-persona-badge{position:static;flex:none}',
705
+ // 维度列表:label 左、值右,两列对齐
706
+ '.soul-persona-dims{display:flex;flex-direction:column;gap:2px;flex:none}',
707
+ '.soul-persona-dim{display:flex;align-items:baseline;justify-content:space-between;gap:8px;font-size:11px;line-height:16px}',
708
+ '.soul-persona-dim span{flex:none;color:var(--dsw-alias-label-tertiary)}',
709
+ '.soul-persona-dim b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-weight:500}',
710
+ '.soul-persona-back-label{flex:none;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
711
+ // 全文:**不截断**(正面效果栏才截断)。
712
+ // 滚动放在**这一块自己**身上(不是 face),这样「返回」按钮始终贴在卡底可见 ——
713
+ // 实测把 overflow-y 放 face 上时,长文本会把按钮一起推出视野并被文字压住。
714
+ // 需要的背景:卡片高度固定 372px,而实测 339 字占 272px(17 行 × 16px),
715
+ // 加头部/维度/按钮后已无余量,稍长就必然要滚。
716
+ '.soul-persona-fulltext{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;cursor:text;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;white-space:pre-wrap;word-break:break-word}',
717
+ // 背面的「返回」:贴底按钮,做成描边式(不抢视觉,但必须好点)。
718
+ //
719
+ // ⚠️ 选择器必须带 .soul-persona-face-back 前缀(提高权重),不能只写
720
+ // `.soul-persona-back-btn`:基类 `.soul-persona-action` 有 `flex:1 1 auto`,
721
+ // 而两者都是单类选择器(权重相同)⇒ **后声明者胜**。本规则在源码里排在
722
+ // 基类之前,于是基类的 flex:1 覆盖了这里的 flex:none —— 后果是自定义预设
723
+ // 指令较短时,剩余空间把「返回」按钮**拉伸到 98px 高**(实测:短文本 98px
724
+ // vs 长文本 21px)。加前缀后权重更高,与声明顺序无关。
725
+ // 卡背底部按钮行:返回(弹性占满)+ 删除(自建卡才有,定宽)。
726
+ // .soul-persona-back-btn 的 margin-top:auto 移到本行容器上 —— 按钮行整体贴底。
727
+ '.soul-persona-back-actions{display:flex;align-items:center;gap:8px;flex:none;margin-top:auto}',
728
+ '.soul-persona-back-btn{flex:1 1 auto}',
729
+ // 删除在卡背是「次要管理操作」:定宽描边(armed 态复用正面同款变实心样式)
730
+ '.soul-persona-back-del{flex:0 0 64px}',
731
+ '.soul-persona-details{flex:0 0 auto;padding:0 10px;background:transparent;color:var(--dsw-alias-label-secondary);box-shadow:inset 0 0 0 .5px var(--dsw-alias-settings-card-stroke);font-size:12px;font-weight:500}',
732
+ '.soul-persona-details:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);filter:none}',
733
+ // 插画区:正方形主视觉。非内置预设没有配套插画,用主题色渐变兜底
734
+ // 所有卡片都出图(自建预设由宿主返回专用默认贴图);图未就绪时露出渐变底。
735
+ '.soul-persona-art{position:relative;width:100%;aspect-ratio:1/1;flex:none;overflow:hidden;background:linear-gradient(150deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 22%,var(--dsw-alias-bg-module-platform)),var(--dsw-alias-bg-module-platform))}',
736
+ '.soul-persona-art-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;user-select:none}',
737
+ // 底部渐变:让压在图上的名称/摘要有足够对比度(浅色插画上白字会糊)
738
+ '.soul-persona-art-veil{position:absolute;inset:auto 0 0 0;height:62%;background:linear-gradient(180deg,rgba(10,14,35,0),rgba(10,14,35,.62) 55%,rgba(10,14,35,.86));pointer-events:none}',
739
+ // 名称 + 摘要压在图底部
740
+ '.soul-persona-art-title{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:2px;min-width:0}',
741
+ '.soul-persona-name{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-static-neutral-bluish-00,#fff);font-size:14px;font-weight:600;line-height:20px}',
742
+ '.soul-persona-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 1px 6px rgba(0,0,0,.5)}',
743
+ '.soul-persona-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-static-neutral-bluish-100,#d3dbf5);font-size:11px;line-height:16px;text-shadow:0 1px 6px rgba(0,0,0,.5)}',
744
+ // 内置徽标:图上用半透明玻璃底(白底徽标在深色图上会太跳)
745
+ '.soul-persona-badge{position:absolute;top:10px;left:10px;flex:none;padding:2px 7px;border-radius:999px;border:1px solid rgba(255,255,255,.34);background:rgba(255,255,255,.2);color:var(--dsw-static-neutral-bluish-00,#fff);font-size:10px;font-weight:600;line-height:14px;letter-spacing:.3px;backdrop-filter:blur(8px)}',
746
+ // 位置指示:图上右上角的胶囊
747
+ '.soul-persona-card-pos{position:absolute;top:10px;right:10px;padding:2px 7px;border-radius:999px;background:rgba(10,14,35,.5);color:var(--dsw-static-neutral-bluish-00,#fff);font-size:10px;line-height:14px;font-variant-numeric:tabular-nums;backdrop-filter:blur(8px)}',
748
+ // 信息区:效果说明 + 操作按钮
749
+ '.soul-persona-body{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0;padding:10px 12px 12px}',
750
+ '.soul-persona-eff{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}',
751
+ // 非当前卡片是「背景层」:可点击切过去,但不可抢注意力
752
+ '.soul-persona-card[data-current="false"]{cursor:pointer}',
753
+ // 「翻开」动画:切张时当前卡片轻微放大,给出卡牌被拨动的实感
754
+ '.soul-persona-card[data-current="true"]{animation:soul-card-in .3s cubic-bezier(.22,.61,.36,1) both}',
755
+ '@keyframes soul-card-in{from{opacity:.72;transform:translateX(0) scale(.96)}to{opacity:1;transform:translateX(0) scale(1)}}',
756
+ // 位置指示与翻页:竖版卡牌一次只见一张,这三处是对该损失的补偿
757
+ '.soul-persona-nav{display:flex;align-items:center;justify-content:center;gap:12px}',
758
+ '.soul-persona-navbtn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-settings-card-fill);color:var(--dsw-alias-label-secondary);font:inherit;font-size:16px;line-height:1;cursor:pointer;transition:background-color .15s ease,color .15s ease}',
759
+ '.soul-persona-navbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
760
+ '.soul-persona-navbtn:disabled{cursor:default;opacity:.3}',
761
+ '.soul-persona-navbtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
762
+ '.soul-persona-dots{display:flex;align-items:center;gap:6px;min-width:0;flex-wrap:wrap;justify-content:center}',
763
+ '.soul-persona-dot{width:6px;height:6px;padding:0;border:0;border-radius:50%;background:var(--dsw-alias-label-tertiary);opacity:.35;cursor:pointer;transition:opacity .15s ease,transform .15s ease}',
764
+ '.soul-persona-dot[data-active="true"]{opacity:1;transform:scale(1.3);background:var(--dsw-alias-brand-primary)}',
765
+ '.soul-persona-dot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
766
+ // 操作区:固定宽度 + 两个等宽轨道,「使用」永远在第 1 轨、删除位永远在第 2 轨。
767
+ // 间距对齐宿主 .zGbnIq_rowActions 的 gap:4px;margin-top:auto 让它贴卡片底部,
768
+ // 于是不同摘要长度的卡片按钮仍对齐(堆叠容器高度固定,不会被内容撑变)。
769
+ // 操作区:「使用」是卡牌主操作(要突出),弹性占满可用宽度;
770
+ // 「删除」是次要破坏性操作,定宽小按钮,不抢主次。
771
+ // 内置预设无删除 ⇒ 不渲染删除按钮,「使用」自然占满整行。
772
+ '.soul-persona-actions{display:flex;align-items:center;gap:8px;margin-top:auto;padding-top:2px}',
773
+ // 主按钮:走主题的主按钮色族(brand 底 + 反色前景)。
774
+ // 为什么不再沿用「对齐宿主 linkButton」的 28px 透明底:那是**列表行**里的
775
+ // 文字按钮规格,而卡牌的主操作需要更强的视觉权重(样张里也是实心主色按钮)。
776
+ '.soul-persona-action{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:5px;height:36px;padding:0 14px;border:0;border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font:inherit;font-size:13px;font-weight:600;line-height:20px;cursor:pointer;white-space:nowrap;flex:1 1 auto;min-width:0;transition:filter .15s ease}',
777
+ '.soul-persona-action:hover:not(:disabled){filter:brightness(1.08)}',
778
+ '.soul-persona-action:active:not(:disabled){filter:brightness(0.96)}',
779
+ '.soul-persona-action:disabled{cursor:default;opacity:.5}',
780
+ '.soul-persona-action:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
781
+ // 当前生效的预设:按钮文字为「使用中」,样式改为**描边式**(透明底 + 品牌色边框与文字),
782
+ // 与其它卡片的实心主色按钮形成对比 —— 这样即使不看名称前的 ★,也能一眼认出当前配置。
783
+ // 仍可点击(再点一次是无变化的幂等操作,宿主会回「配置无变化」),不刻意禁用:
784
+ // 禁用会让用户以为卡住了,而这里并无副作用。
785
+ '.soul-persona-active{background:transparent;color:var(--dsw-alias-brand-primary);box-shadow:inset 0 0 0 .5px var(--dsw-alias-brand-primary)}',
786
+ '.soul-persona-active:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);filter:none}',
787
+ // 删除:定宽描边式危险按钮(43px),明显小于「使用」,形成主次对比
788
+ '.soul-persona-danger{flex:0 0 43px;background:transparent;color:var(--dsw-alias-state-error-primary);border:.5px solid var(--dsw-alias-state-error-primary);font-size:12px;font-weight:500}',
789
+ '.soul-persona-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);filter:none}',
790
+ // 内置预设:不渲染删除按钮(后端也拒绝删除),「使用」占满整行。
791
+ // 类名保留是为了兼容既有断言与「空占位不能是可点控件」这条历史契约。
792
+ '.soul-persona-slot{display:none}',
793
+ // 徽标:对齐宿主的 rowTag 规格(.5px 描边、radius-xs、11px/16px)
794
+ '.soul-persona-badge{border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xs);color:var(--dsw-alias-label-secondary);flex:none;padding:0 6px;font-size:11px;line-height:16px;white-space:nowrap}',
795
+ // ——「存为新预设」命名弹层(方案 A)——
796
+ // 遮罩用 fixed 全屏 + 半透明;层级要低于 toast(toast 是 10000),
797
+ // 因为保存成功后 toast 要能盖在弹层之上被看到。
798
+ '.soul-modal-mask{position:fixed;inset:0;z-index:9000;display:flex;align-items:center;justify-content:center;background:rgba(10,14,35,.42);padding:24px}',
799
+ '.soul-modal{box-sizing:border-box;width:100%;max-width:380px;display:flex;flex-direction:column;gap:14px;padding:18px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-settings-card-stroke);background:var(--dsw-alias-settings-card-fill);box-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(0,0,0,.18))}',
800
+ '.soul-modal-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:22px}',
801
+ '.soul-modal .soul-field{margin-bottom:0}',
802
+ // 「将保存 / 不含」两行说明:这是消除「不知道存了哪些值」的关键,用弱化样式但仍可读
803
+ '.soul-modal-note{display:flex;flex-direction:column;gap:4px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
804
+ '.soul-modal-note-excl{color:var(--dsw-alias-label-tertiary)}',
805
+ '.soul-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
806
+ // 二次确认态:描边 → 实心危险色,**并放宽按钮**。
807
+ // 放宽是必须的:普通态按「删除」两字定宽 43px,而「确认删除?」是五个字,
808
+ // 沿用 43px 会把文字挤到贴边/溢出(实测截图确认)。此时它是需要用户看清的
809
+ // 关键动作,宽度优先于「不与主按钮抢视觉」这条次要原则。
810
+ '.soul-persona-danger-armed{flex:0 0 auto;padding:0 10px;background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-label-primary-foreground);border-color:var(--dsw-alias-state-error-primary)}',
811
+ '.soul-persona-danger-armed:hover:not(:disabled){background:var(--dsw-alias-state-error-primary);filter:brightness(1.08)}',
812
+ // 提示词预览:宿主用 bg-module-platform 表达「内嵌代码/只读区」
813
+ '.soul-prompt-pre{margin:8px 0 0 0;padding:12px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto;font-family:var(--ds-font-family-code,Consolas,Menlo,monospace);text-align:left;width:100%;box-sizing:border-box}',
814
+ // —— 保存成功的内联绿字 ——
815
+ // 规格照抄宿主模型设置页的 .savedNotice(zGbnIq_savedNotice):
816
+ // color:var(--dsw-alias-state-success-primary)、margin:0、12px/18px。
817
+ // 位置也一致:标题区之后、分组列表之前。生命周期不自动消失,
818
+ // 由下一次编辑(dirty)清除 —— 见组件里的 effect。
819
+ // 「已偏离任何预设」的出口:一行提示 + 一个行动按钮。
820
+ // 用与宿主一致的弱化文字色 + 品牌色可点文字(不是实心按钮 —— 它是提示性的
821
+ // 建议动作,不该与「使用」抢视觉权重)。
822
+ '.soul-drift{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;padding:8px 12px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-module-platform)}',
823
+ '.soul-drift-text{min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
824
+ '.soul-drift-action{box-sizing:border-box;display:inline-flex;align-items:center;height:24px;padding:0 10px;border:.5px solid var(--dsw-alias-brand-primary);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}',
825
+ '.soul-drift-action:hover{background:var(--dsw-alias-interactive-bg-hover)}',
826
+ '.soul-drift-action:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}', '.soul-saved-notice{margin:0;color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:18px}',
827
+ // 失败红字:规格照抄宿主 .zGbnIq_error(state-error-primary、margin:0、12px/18px),
828
+ // role=alert。与绿字同位互斥(失败压过成功),都不自动消失。
829
+ '.soul-error-notice{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}',
556
830
  // —— 输入框光轨 ——
557
831
  // 几何规则(position/尺寸/contain)同时内联在元素上,见 mountTrailRing;
558
832
  // 这里保留一份等价声明作为兜底,并让样式可读、可被第三方主题安全覆盖(仅颜色/速度/粗细)
@@ -588,14 +862,16 @@ window.__ModuleLoader__.load({
588
862
  '[data-composer-card][data-soul-trail="on"]{--dsw-elevation-stroke-color:transparent}',
589
863
  // 设置页:颜色控件与实时示例
590
864
  '.soul-trail-colors{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
591
- '.soul-trail-swatch{width:26px;height:26px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer}',
865
+ '.soul-trail-swatch{width:26px;height:26px;padding:0;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);cursor:pointer}',
592
866
  '.soul-trail-swatch[data-active="true"]{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
593
- '.soul-trail-picker{width:34px;height:26px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:none;cursor:pointer}',
594
- '.soul-field .soul-trail-colors input.soul-trail-hex{width:104px;padding:5px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.4;font-family:Consolas,Menlo,monospace;box-sizing:border-box}',
595
- '.soul-trail-preview{position:relative;margin-top:6px;padding:16px;border:1px solid var(--dsw-alias-border-l2);border-radius:22px;background:var(--dsw-alias-bg-layer-1)}',
867
+ '.soul-trail-picker{width:34px;height:26px;padding:0;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:none;cursor:pointer}',
868
+ '.soul-field .soul-trail-colors input.soul-trail-hex{width:104px;padding:5px 8px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.4;font-family:Consolas,Menlo,monospace;box-sizing:border-box}',
869
+ '.soul-trail-preview{position:relative;margin-top:6px;padding:16px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}',
596
870
  '.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
597
871
  '.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
598
- '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}}'
872
+ // 开了「减少动态效果」时:光轨层与卡片停动画。
873
+ // 结果提示(绿字/红字)不需要在这里照顾 —— 它们是静态文字、没有动画。
874
+ '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}.soul-persona-card{animation:none!important;transition:none}}'
599
875
  ].join('')
600
876
 
601
877
  const tagId = 'dsh-soul/styles.css'
@@ -624,6 +900,8 @@ window.__ModuleLoader__.load({
624
900
  'settings.title': '个性化设置',
625
901
  'accordion.filled': '项已填写',
626
902
  'accordion.empty': '未填写',
903
+ // 分组摘要在「拉取失败」时的显示(折叠态下用户只看得到这一行)
904
+ 'accordion.loadFailed': '加载失败',
627
905
  'accordion.personas': '个预设',
628
906
  'accordion.on': '已开启',
629
907
  'accordion.off': '未开启',
@@ -631,7 +909,10 @@ window.__ModuleLoader__.load({
631
909
  'quick.enable': '开启个性化',
632
910
  'quick.disable': '关闭个性化',
633
911
  'quick.statusEnabled': '个性化已启用',
912
+ 'quick.custom': '自定义',
634
913
  'quick.statusDisabled': '个性化已关闭',
914
+ // 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
915
+ 'quick.toggleFailed': '保存失败,请重试',
635
916
  'group.aboutYou': '关于你',
636
917
  'field.nickname': '用户昵称',
637
918
  'field.nicknamePlaceholder': '输入你的昵称,回复时会称呼你',
@@ -657,13 +938,34 @@ window.__ModuleLoader__.load({
657
938
  'field.instructionsPlaceholder': '输入自定义指令,可以是其他行为、回复风格和语调等偏好设置',
658
939
  'group.personas': '人设预设',
659
940
  'personas.save': '保存当前为预设',
941
+ // 方案 A:入口移出预设列表,与「保存设置」并列(文案用「存为预设…」表示会再问一步)
942
+ 'personas.saveAs': '存为预设…',
943
+ 'personas.saveNameLabel': '新预设名称',
944
+ 'personas.saveConfirm': '保存',
945
+ 'personas.saveCovers': '将保存:回复风格和语调、特质、回复长度、输出语言、自定义指令',
946
+ 'personas.saveExcludes': '不含:昵称、职业、介绍(这些属于「关于你」,预设不动它们)',
947
+ 'personas.confirmDeleteShort': '确认删除?',
948
+ // —— 卡背(详情面)——
949
+ 'personas.details': '详情',
950
+ 'personas.flipBack': '返回',
951
+ 'personas.dims': '人设维度',
952
+ 'personas.customInstructions': '自定义指令',
953
+ 'personas.dimDefault': '默认',
954
+ 'personas.noCustomInstructions': '这个人设没有额外的自定义指令',
660
955
  'personas.savePlaceholder': '预设名称(1-30 字符)',
661
956
  'personas.use': '使用',
957
+ 'personas.active': '使用中',
662
958
  'personas.delete': '删除',
959
+ 'personas.prev': '上一个预设',
960
+ 'personas.next': '下一个预设',
663
961
  'personas.confirmDelete': '确定删除预设「{name}」?',
664
962
  'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
963
+ // 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
964
+ 'personas.loadFailed': '人设预设加载失败:{reason}',
665
965
  'personas.builtin': '内置',
666
- 'personas.hint': '内置预设随插件提供,不可删除;保存当前配置可创建自己的预设',
966
+ 'personas.drift': '当前配置与任何预设都不一致。',
967
+ 'personas.driftAction': '存为新预设',
968
+ 'personas.hint': '预设只保存 Agent 的人设(风格 / 特质 / 回复长度 / 输出语言 / 自定义指令),不含「关于你」;内置预设随插件提供,不可删除',
667
969
  'style.professional': '专业严谨',
668
970
  'style.casual': '轻松自然',
669
971
  'style.humorous': '幽默风趣',
@@ -694,21 +996,36 @@ window.__ModuleLoader__.load({
694
996
  'meta.language.en': '英文',
695
997
  'group.tool': 'Agent 工具',
696
998
  'field.toolConfirm': '人设变更需确认',
697
- 'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
999
+ 'hint.toolConfirm': '开启后,Agent 改人设会先在输入框处弹出审批面板(Enter 允许 / Esc 拒绝)。若会话审批通道不可用(策略为 never),则退化为待确认提议,需用 /soul confirm 处理 —— 此时建议关闭本开关,否则每次改动都要手输命令。',
698
1000
  'button.save': '保存设置',
699
1001
  'button.saving': '保存中...',
1002
+ 'button.cancel': '取消',
700
1003
  'button.reset': '重置默认',
701
1004
  'status.loading': '加载中...',
702
1005
  'status.unsaved': '有未保存的更改',
1006
+ // —— 内联绿字(对齐宿主模型设置页的 savedNotice)——
1007
+ // 保存类成功提示不再走 toast(宿主那里根本不用 toast 报成功),
1008
+ // 文案对齐宿主语气:句末带句号、不带 emoji(宿主绿字没有装饰符号)
1009
+ 'notice.saved': '设置已保存。',
1010
+ 'notice.noChanges': '配置无变化。',
1011
+ 'notice.reset': '已重置为默认值。',
1012
+ 'notice.resetRecovered': '已重置为默认值(损坏的配置已备份)。',
1013
+ 'notice.personaSaved': '已保存预设「{name}」。',
1014
+ 'notice.personaUsed': '已应用预设「{name}」。',
1015
+ 'notice.personaUnchanged': '预设与当前配置一致。',
1016
+ 'notice.personaDeleted': '已删除预设「{name}」。',
1017
+ // 以下错误类仍走 toast(打断性提示)
703
1018
  'toast.saved': '✅ 设置已保存',
704
1019
  'toast.noChanges': '✅ 配置无变化',
705
1020
  'toast.reset': '✅ 已重置为默认值',
1021
+ 'toast.resetRecovered': '✅ 已重置为默认值(损坏的配置已备份)',
1022
+ 'toast.resetFailed': '❌ 重置失败',
706
1023
  'toast.personaSaved': '✅ 预设已保存',
707
1024
  'toast.personaUsed': '✅ 预设已应用',
1025
+ // 预设含非法取值时服务端只应用合法部分,被跳过的字段必须回报
1026
+ 'toast.personaPartial': '⚠ 预设仅部分应用,以下字段取值非法已跳过:{fields}',
708
1027
  'toast.personaUnchanged': 'ℹ 预设与当前配置一致',
709
1028
  'toast.personaDeleted': '✅ 预设已删除',
710
- 'prompt.view': '查看当前生效提示词',
711
- 'prompt.hide': '收起提示词',
712
1029
  'prompt.title': '当前生效提示词',
713
1030
  'prompt.summaryEnabled': '已启用',
714
1031
  'prompt.summaryDisabled': '已禁用',
@@ -719,6 +1036,9 @@ window.__ModuleLoader__.load({
719
1036
  'prompt.titleDraft': '保存后将生效的提示词',
720
1037
  'prompt.summaryDraft': '{n} 字符 · 未保存',
721
1038
  'prompt.invalid': '以下字段未通过校验,未计入本次预览:{fields}',
1039
+ // 列表分隔符:中文用顿号、英文用逗号 + 空格。此前英文界面里也硬编码「、」,
1040
+ // 会渲染成 `...excluded from this preview: style、tables`(中英混排)。
1041
+ 'common.listSep': '、',
722
1042
  'toast.saveFailed': '❌ 保存失败,请检查配置',
723
1043
  'trail.title': '输入框光轨',
724
1044
  'trail.enable': '启用输入框光轨',
@@ -741,6 +1061,8 @@ window.__ModuleLoader__.load({
741
1061
  'settings.title': 'Personalization Settings',
742
1062
  'accordion.filled': 'fields filled',
743
1063
  'accordion.empty': 'No fields set',
1064
+ // 分组摘要在「拉取失败」时的显示(折叠态下用户只看得到这一行)
1065
+ 'accordion.loadFailed': 'Failed to load',
744
1066
  'accordion.personas': 'personas',
745
1067
  'accordion.on': 'enabled',
746
1068
  'accordion.off': 'disabled',
@@ -748,7 +1070,10 @@ window.__ModuleLoader__.load({
748
1070
  'quick.enable': 'Enable personalization',
749
1071
  'quick.disable': 'Disable personalization',
750
1072
  'quick.statusEnabled': 'Personalization enabled',
1073
+ 'quick.custom': 'Custom',
751
1074
  'quick.statusDisabled': 'Personalization disabled',
1075
+ // 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
1076
+ 'quick.toggleFailed': 'Save failed, please retry',
752
1077
  'group.aboutYou': 'About you',
753
1078
  'field.nickname': 'Nickname',
754
1079
  'field.nicknamePlaceholder': 'Your nickname — the agent will address you by it',
@@ -774,13 +1099,32 @@ window.__ModuleLoader__.load({
774
1099
  'field.instructionsPlaceholder': 'Custom instructions — other behaviors, style or tone preferences',
775
1100
  'group.personas': 'Personas',
776
1101
  'personas.save': 'Save current as persona',
1102
+ 'personas.saveAs': 'Save as persona…',
1103
+ 'personas.saveNameLabel': 'New persona name',
1104
+ 'personas.saveConfirm': 'Save',
1105
+ 'personas.saveCovers': 'Saves: reply style & tone, traits, reply length, reply language, custom instructions',
1106
+ 'personas.saveExcludes': 'Excludes: nickname, occupation, bio (those are "About you" — personas never touch them)',
1107
+ 'personas.confirmDeleteShort': 'Confirm delete?',
1108
+ 'personas.details': 'Details',
1109
+ 'personas.flipBack': 'Back',
1110
+ 'personas.dims': 'Persona dimensions',
1111
+ 'personas.customInstructions': 'Custom instructions',
1112
+ 'personas.dimDefault': 'Default',
1113
+ 'personas.noCustomInstructions': 'This persona adds no custom instructions',
777
1114
  'personas.savePlaceholder': 'Persona name (1-30 chars)',
778
1115
  'personas.use': 'Use',
1116
+ 'personas.active': 'Active',
779
1117
  'personas.delete': 'Delete',
1118
+ 'personas.prev': 'Previous persona',
1119
+ 'personas.next': 'Next persona',
780
1120
  'personas.confirmDelete': 'Delete persona "{name}"?',
781
1121
  'personas.empty': 'No personas yet — save the current config to switch with one click',
1122
+ // 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
1123
+ 'personas.loadFailed': 'Failed to load personas: {reason}',
782
1124
  'personas.builtin': 'Built-in',
783
- 'personas.hint': 'Built-in personas ship with the plugin and cannot be deleted; save the current config to create your own',
1125
+ 'personas.drift': 'The current config matches no preset.',
1126
+ 'personas.driftAction': 'Save as new preset',
1127
+ 'personas.hint': 'A persona saves the agent personality only (style / traits / reply length / reply language / custom instructions), never your own identity fields; built-ins ship with the plugin and cannot be deleted',
784
1128
  'style.professional': 'Professional',
785
1129
  'style.casual': 'Casual',
786
1130
  'style.humorous': 'Humorous',
@@ -811,21 +1155,33 @@ window.__ModuleLoader__.load({
811
1155
  'meta.language.en': 'English',
812
1156
  'group.tool': 'Agent tools',
813
1157
  'field.toolConfirm': 'Require confirmation for persona changes',
814
- 'hint.toolConfirm': 'When enabled, changes made by the agent through the set_persona tool do not apply immediately — confirm with /soul confirm or reject with /soul reject in the conversation.',
1158
+ 'hint.toolConfirm': 'When on, persona changes by the agent first raise an approval panel above the input box (Enter = allow, Esc = reject). If the session approval channel is unavailable (policy = never), it falls back to a pending proposal applied only via /soul confirm. Under full permissions the panel never appears, so turning this switch off is recommended.',
815
1159
  'button.save': 'Save Settings',
816
1160
  'button.saving': 'Saving...',
1161
+ 'button.cancel': 'Cancel',
817
1162
  'button.reset': 'Reset to Default',
818
1163
  'status.loading': 'Loading...',
819
1164
  'status.unsaved': 'You have unsaved changes',
1165
+ // Inline success notice (mirrors the host models page savedNotice)
1166
+ 'notice.saved': 'Settings saved.',
1167
+ 'notice.noChanges': 'No changes to save.',
1168
+ 'notice.reset': 'Reset to defaults.',
1169
+ 'notice.resetRecovered': 'Reset to defaults (the damaged config was backed up).',
1170
+ 'notice.personaSaved': 'Saved persona "{name}".',
1171
+ 'notice.personaUsed': 'Applied persona "{name}".',
1172
+ 'notice.personaUnchanged': 'Persona matches current config.',
1173
+ 'notice.personaDeleted': 'Deleted persona "{name}".',
820
1174
  'toast.saved': '✅ Settings saved',
821
1175
  'toast.noChanges': '✅ No changes to save',
822
1176
  'toast.reset': '✅ Reset to defaults',
1177
+ 'toast.resetRecovered': '✅ Reset to defaults (the damaged config was backed up)',
1178
+ 'toast.resetFailed': '❌ Reset failed',
823
1179
  'toast.personaSaved': '✅ Persona saved',
824
1180
  'toast.personaUsed': '✅ Persona applied',
1181
+ // 预设含非法取值时服务端只应用合法部分,被跳过的字段必须回报
1182
+ 'toast.personaPartial': '⚠ Persona applied partially; skipped as invalid: {fields}',
825
1183
  'toast.personaUnchanged': 'ℹ Persona matches current config',
826
1184
  'toast.personaDeleted': '✅ Persona deleted',
827
- 'prompt.view': 'View the active system prompt',
828
- 'prompt.hide': 'Hide the prompt',
829
1185
  'prompt.title': 'Active system prompt',
830
1186
  'prompt.summaryEnabled': 'Enabled',
831
1187
  'prompt.summaryDisabled': 'Disabled',
@@ -836,6 +1192,9 @@ window.__ModuleLoader__.load({
836
1192
  'prompt.titleDraft': 'Prompt after saving',
837
1193
  'prompt.summaryDraft': '{n} chars · unsaved',
838
1194
  'prompt.invalid': 'These fields failed validation and are excluded from this preview: {fields}',
1195
+ // 列表分隔符:中文用顿号、英文用逗号 + 空格。此前英文界面里也硬编码「、」,
1196
+ // 会渲染成 `...excluded from this preview: style、tables`(中英混排)。
1197
+ 'common.listSep': ', ',
839
1198
  'toast.saveFailed': '❌ Save failed — please check the config',
840
1199
  'trail.title': 'Composer light trail',
841
1200
  'trail.enable': 'Enable composer light trail',
@@ -913,9 +1272,12 @@ window.__ModuleLoader__.load({
913
1272
  // 编译结果,不该因此重新请求,也不该让预览被标成「未保存」。
914
1273
  const PROMPT_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
915
1274
 
916
- // 提示词小图标:hover 展示说明文字
1275
+ // 提示词小图标:hover / 键盘聚焦都展示说明文字。
1276
+ // 此前只有 :hover 可达,且提示节点用 visibility:hidden —— 那会把它移出无障碍树,
1277
+ // 键盘与读屏用户拿不到字段说明。这里补 tabIndex 让它可以聚焦,并在 :focus-visible
1278
+ // 下显示提示(同时保留 aria-label,聚焦时读屏会念出说明本身)。
917
1279
  function SoulHint(props) {
918
- return e('span', { className: 'soul-hint', 'aria-label': props.text },
1280
+ return e('span', { className: 'soul-hint', 'aria-label': props.text, tabIndex: 0 },
919
1281
  e('svg', {
920
1282
  viewBox: '0 0 24 24',
921
1283
  width: 14,
@@ -961,33 +1323,54 @@ window.__ModuleLoader__.load({
961
1323
  const state = useSoulController((state) => state)
962
1324
  const enabled = state.enabled === true
963
1325
  const busy = state.saving === true
1326
+ // B:底栏开关上缀当前人设名,与输入框上方的标签互相印证
1327
+ const activePersona = state.activePersona
1328
+
1329
+ // 保存失败的本地标记:失败时 store.error 会带上原因,但按钮本身此前毫无反馈
1330
+ // (enabled 没变 ⇒ 外观与文案都不变),用户视角是「点了没反应」。
1331
+ const [failed, setFailed] = React.useState(false)
1332
+ const failureText = failed ? (state.error || t('quick.toggleFailed')) : null
964
1333
 
965
1334
  const toggle = async () => {
966
1335
  if (busy) return
967
- await controller.saveConfig({ enabled: !enabled })
1336
+ setFailed(false)
1337
+ const payload = await controller.saveConfig({ enabled: !enabled })
1338
+ // saveConfig 失败时返回 undefined;成功时返回载荷
1339
+ setFailed(!payload)
968
1340
  }
969
1341
 
970
1342
  return e('button', {
971
1343
  type: 'button',
972
- className: 'soul-quick-toggle',
1344
+ className: failed ? 'soul-quick-toggle soul-quick-toggle-failed' : 'soul-quick-toggle',
973
1345
  'data-enabled': enabled ? 'true' : 'false',
1346
+ 'data-failed': failed ? 'true' : 'false',
974
1347
  'aria-pressed': enabled,
975
- 'aria-label': enabled ? t('quick.disable') : t('quick.enable'),
976
- title: enabled ? t('quick.disable') : t('quick.enable'),
1348
+ // 失败时把原因写进 aria-label / title:否则按钮外观不变,用户无从得知保存失败
1349
+ 'aria-label': failureText || (enabled ? t('quick.disable') : t('quick.enable')),
1350
+ title: failureText || (enabled ? t('quick.disable') : t('quick.enable')),
977
1351
  disabled: busy,
978
1352
  onClick: toggle
979
1353
  },
980
1354
  e('span', { className: 'soul-quick-toggle-dot', 'aria-hidden': true }),
981
- enabled ? t('quick.statusEnabled') : t('quick.statusDisabled')
1355
+ enabled ? t('quick.statusEnabled') : t('quick.statusDisabled'),
1356
+ // 启用时**始终**缀上「当前是哪套人设」:匹配到预设显示预设名,
1357
+ // 未匹配(手改过特质、或纯自定义配置)显示「自定义」。
1358
+ //
1359
+ // 为什么不再留空:留空等于「我现在用的到底是什么」没有答案,而
1360
+ // 「未匹配预设」这种说法听着像报错。底栏这里是输入框旁唯一的常驻状态,
1361
+ // 必须给确定的落点 —— 有名字就是那个预设,没名字就是自定义。
1362
+ // 关闭个性化时不显示(人设此时不生效,挂名字会误导)。
1363
+ enabled && e('span', { className: 'soul-quick-toggle-name' },
1364
+ `· ${activePersona || t('quick.custom')}`)
982
1365
  )
983
1366
  }
984
1367
 
985
- function SoulSettings(props) {
1368
+ function SoulSettings(props) {
986
1369
  const { useSoulController, controller } = props
987
1370
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
988
1371
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
989
1372
  const state = useSoulController((state) => state)
990
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, loading, saving, error } = state
1373
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, personasError, saving, error } = state
991
1374
 
992
1375
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
993
1376
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
@@ -1006,7 +1389,59 @@ window.__ModuleLoader__.load({
1006
1389
  const [localTrailSpeed, setLocalTrailSpeed] = React.useState(trailSpeed || 'slow')
1007
1390
  const [localTrailWidth, setLocalTrailWidth] = React.useState(trailWidth || 'thin')
1008
1391
  const [personaName, setPersonaName] = React.useState('')
1009
- const [toast, setToast] = React.useState(null)
1392
+ // 「存为预设」的命名弹层是否打开。
1393
+ //
1394
+ // 方案 A:把「保存当前为预设」从预设列表里**移出来**,做成「保存设置」旁的同级动作,
1395
+ // 点开才问名字。原因(用户反馈「比较迷茫」):
1396
+ // ① 它原先被塞在预设列表最下面,而用户此时的心理模型是「我在看别人的预设」,
1397
+ // 突然出现「保存当前」的输入框,说不清「当前」指什么;
1398
+ // ② 真正被保存的是「关于你/特质」里的字段,那些字段在**别的分组**里,
1399
+ // 用户在预设区输入名字,不知道到底存了哪些值;
1400
+ // ③ 「保存设置」与「保存当前为预设」两个保存并列,语义没区分;
1401
+ // 且名字要先想好才能点(disabled 依赖非空),交互顺序也别扭。
1402
+ const [showSavePersona, setShowSavePersona] = React.useState(false)
1403
+ // 待删除的预设名(null = 没有待确认的删除)。
1404
+ //
1405
+ // 替代原生 window.confirm:原生模态框在 webview 里可能把文档焦点留在异常状态
1406
+ // (表现为「页面看得见却点不动、打不了字」,最小化再恢复才好)。宿主自己的设置页
1407
+ // 也不用原生 confirm,这里改为**内联二次确认**:第一次点「删除」把按钮切换成
1408
+ // 「确认删除?」,再点一次才真删;点别处或超时则复位。
1409
+ const [pendingDelete, setPendingDelete] = React.useState(null)
1410
+ // 卡片堆叠轮播的当前索引(人设预设一次只展示一张,左右可滑动/翻页)。
1411
+ // 索引在预设数组变化后可能越界(删除、加载失败后重取),渲染时统一夹取,
1412
+ // 不在 effect 里回写 —— 回写会多一次渲染,且删除后「看过的那张」位置语义
1413
+ // 本就有歧义(见 personaIndex 的夹取逻辑)。
1414
+ const [personaIndex, setPersonaIndex] = React.useState(0)
1415
+ // 卡背(详情面)状态:存「哪一张卡被翻到了背面」。
1416
+ //
1417
+ // 设计要点(这是 E 方案最容易做错的地方):
1418
+ // **卡背不是轮播的第 N+1 张,而是当前卡的另一种「面」** —— 所以索引
1419
+ // index/total/go()/自动定位全部不用改,翻页翻的是「卡」,翻面翻的是「面」。
1420
+ // 若把卡背也算一张,total 要变 2N、位置指示与圆点全要跟着改,得不偿失。
1421
+ //
1422
+ // 状态绑定在**人设名**而不是索引:删除/刷新后同名的卡仍是同一张,
1423
+ // 绑索引会在列表变短时翻错面。
1424
+ const [flippedName, setFlippedName] = React.useState(null)
1425
+ // —— 内联结果提示(对齐宿主模型设置页的全局形态)——
1426
+ //
1427
+ // 查证:宿主设置页(dsh-client-ui-settings-models)**从不使用 toast** ——
1428
+ // 成功是顶部一行绿字(.savedNotice,role=status),失败是内联红字
1429
+ // (.error,role=alert),两者同字号(12px/18px)、就地出现、不自动消失。
1430
+ // 因此这里对称地用两个状态承载:savedNotice(绿)/ errorNotice(红),
1431
+ // 均在「下一次操作开始」时被替换,编辑时清除绿字。
1432
+ const [savedNotice, setSavedNotice] = React.useState(null)
1433
+ const showSavedNotice = (text) => setSavedNotice(text)
1434
+ const [errorNotice, setErrorNotice] = React.useState(null)
1435
+ const showError = (text) => {
1436
+ // 失败要压过之前的成功提示(用户要知道最新一次操作的结果)
1437
+ setSavedNotice(null)
1438
+ setErrorNotice(text)
1439
+ }
1440
+ //
1441
+ // 查证:宿主保存成功的提示不是 toast,而是设置页顶部的一行绿字
1442
+ // (.savedNotice:state-success-primary 绿、12px/18px、role:status +
1443
+ // aria-live:polite),且**不会自动消失** —— 只在开始编辑/删除/新增时清除。
1444
+ // 这里保持同一生命周期:下一次 dirty(开始编辑)时清除,见下方 effect。
1010
1445
  const [showPrompt, setShowPrompt] = React.useState(false)
1011
1446
  const [promptText, setPromptText] = React.useState('')
1012
1447
  const [promptLoading, setPromptLoading] = React.useState(false)
@@ -1033,42 +1468,92 @@ window.__ModuleLoader__.load({
1033
1468
  }, [openSection])
1034
1469
 
1035
1470
  // 同步状态
1471
+ // 用 store 回填本地表单 —— 但**按字段**决定,而不是一刀切。
1472
+ //
1473
+ // 此前是「dirty || saving 就整体不回填」:点「使用」应用预设后,store 先变、
1474
+ // 本地表单还是旧值 ⇒ 那一次渲染 dirty 恒为 true ⇒ 回填被挡 ⇒ 表单停在旧值。
1475
+ // 之后「保存设置」亮起(用户没改任何东西也亮),一点保存就把**旧表单值**
1476
+ // 整体 POST 上去,把刚应用的预设覆盖回旧配置 —— 正是「使用后人设变回默认」。
1477
+ //
1478
+ // 现在的判据:记录「上次与 store 同步时的本地快照」。某字段的当前本地值
1479
+ // 仍等于快照 ⇒ 用户没动过它 ⇒ 安全回填新值;不等于 ⇒ 用户正在编辑 ⇒ 保留。
1480
+ // 既有保护(轮询不覆盖正在编辑的内容)逐字段保留,语义不变。
1481
+ //
1482
+ // saving 期间仍然整体跳过:保存流程自身会带着响应回填(见 saveConfig →
1483
+ // applyConfig → 本 effect),中途再插一次写入只会造成闪烁。
1484
+ const formKeys = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
1485
+ const localValues = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, tables: localTables, replyLength: localReplyLength, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm, trailEnabled: localTrailEnabled, trailColor: localTrailColor, trailSpeed: localTrailSpeed, trailWidth: localTrailWidth }
1486
+ const serverValues = { enabled, nickname: nickname || '', occupation: occupation || '', bio: bio || '', style, headingLists, emoji, tables, replyLength, language: language || 'zh', customInstructions, requireToolConfirmation: requireToolConfirmation === true, trailEnabled: trailEnabled === true, trailColor: trailColor || TRAIL_COLOR_FALLBACK, trailSpeed: trailSpeed || 'slow', trailWidth: trailWidth || 'thin' }
1487
+ const lastSyncRef = React.useRef(null)
1488
+ // 判断某字段是否被用户动过:与「上次同步的快照」比。没有快照(首帧)时
1489
+ // 一律视为未动过 —— 首帧本地值本来就来自 store,无从谈起「编辑过」。
1490
+ const userEdited = (key) => {
1491
+ const snap = lastSyncRef.current
1492
+ if (!snap) return false
1493
+ return !sameValue(localValues[key], snap[key])
1494
+ }
1036
1495
  React.useEffect(() => {
1037
- setLocalEnabled(enabled)
1038
- setLocalNickname(nickname || '')
1039
- setLocalOccupation(occupation || '')
1040
- setLocalBio(bio || '')
1041
- setLocalStyle(style)
1042
- setLocalHeadingLists(headingLists)
1043
- setLocalEmoji(emoji)
1044
- setLocalTables(tables)
1045
- setLocalReplyLength(replyLength)
1046
- setLocalLanguage(language || 'zh')
1047
- setLocalInstructions(customInstructions)
1048
- setLocalToolConfirm(requireToolConfirmation === true)
1049
- setLocalTrailEnabled(trailEnabled === true)
1050
- setLocalTrailColor(trailColor || TRAIL_COLOR_FALLBACK)
1051
- setLocalTrailSpeed(trailSpeed || 'slow')
1052
- setLocalTrailWidth(trailWidth || 'thin')
1496
+ if (saving) return
1497
+ const next = {}
1498
+ let changed = false
1499
+ for (const key of formKeys) {
1500
+ // 用户改过的字段一律保留;没动过的字段跟随 store
1501
+ const target = userEdited(key) ? localValues[key] : serverValues[key]
1502
+ next[key] = target
1503
+ if (!sameValue(target, localValues[key])) changed = true
1504
+ }
1505
+ lastSyncRef.current = next
1506
+ if (!changed) return
1507
+ setLocalEnabled(next.enabled)
1508
+ setLocalNickname(next.nickname)
1509
+ setLocalOccupation(next.occupation)
1510
+ setLocalBio(next.bio)
1511
+ setLocalStyle(next.style)
1512
+ setLocalHeadingLists(next.headingLists)
1513
+ setLocalEmoji(next.emoji)
1514
+ setLocalTables(next.tables)
1515
+ setLocalReplyLength(next.replyLength)
1516
+ setLocalLanguage(next.language)
1517
+ setLocalInstructions(next.customInstructions)
1518
+ setLocalToolConfirm(next.requireToolConfirmation)
1519
+ setLocalTrailEnabled(next.trailEnabled)
1520
+ setLocalTrailColor(next.trailColor)
1521
+ setLocalTrailSpeed(next.trailSpeed)
1522
+ setLocalTrailWidth(next.trailWidth)
1523
+ // 依赖与 serverValues 的来源一致;localValues/sameValue 每次渲染都是新的,
1524
+ // 但只在「某字段真要写」时才 setState,故不会形成死循环。
1053
1525
  }, [enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
1054
1526
 
1055
- // toast 自动消失
1056
- React.useEffect(() => {
1057
- if (toast) {
1058
- const timer = setTimeout(() => setToast(null), 2000)
1059
- return () => clearTimeout(timer)
1060
- }
1061
- }, [toast])
1527
+ // toast 的消失交给宿主 Toast 组件自己(holdMs 后淡出并回调 onDone)。
1528
+ // 此前这里有个 2s 定时器 + 自绘 CSS 动画,两者时长必须手工对齐,
1529
+ // 且与全局提示条的 3s + 1s 淡出不一致 ⇒ 已删除。
1062
1530
 
1063
- // dirty 检测:本地表单与已保存配置逐字段比较
1531
+ // dirty 检测:本地表单与已保存配置逐字段比较。
1532
+ //
1533
+ // 字符串一律先 trim 再比:服务端对每个字符串字段都会 trim(文本字段 trim、枚举值
1534
+ // trim、颜色 trim),所以「只改了首尾空格」落盘后与原值完全相同。此前按原始串
1535
+ // 比较会出现「用户敲了个空格 → 保存按钮亮了 → 保存成功却提示『配置无变化』」。
1536
+ // 不另造一份 TEXT_FIELDS 复刻表:那会多一处需要手工同步的常量。
1537
+ const sameValue = (a, b) => (typeof a === 'string' && typeof b === 'string'
1538
+ ? a.trim() === b.trim()
1539
+ : a === b)
1064
1540
  const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
1065
1541
  const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, tables: localTables, replyLength: localReplyLength, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm, trailEnabled: localTrailEnabled, trailColor: localTrailColor, trailSpeed: localTrailSpeed, trailWidth: localTrailWidth }
1066
- const dirty = FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
1542
+ const dirty = FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
1543
+
1544
+ // 宿主 savedNotice 语义:一旦用户开始新的编辑(dirty 变 true),
1545
+ // 上一条「已保存」就过时了 ⇒ 清除。(effect 必须放在 dirty 定义之后:
1546
+ // 依赖数组在渲染期求值,放前面会 TDZ —— 实测踩过。)
1547
+ React.useEffect(() => {
1548
+ if (dirty) setSavedNotice(null)
1549
+ }, [dirty])
1067
1550
 
1068
1551
  // 预览是否反映未保存的编辑:只看参与编译的字段(见 PROMPT_FIELD_KEYS)。
1069
1552
  // 这里用 localMap/savedMap 逐字段比,而不是复用上面的 dirty —— 改光轨颜色会让
1070
1553
  // dirty 为真,但那不影响提示词,此时预览仍是「已生效内容」,标成草稿反而误导。
1071
- const promptDirty = PROMPT_FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
1554
+ // 必须与 dirty 用**同一个** sameValue 判据:否则「只改了首尾空格」时 dirty 为假
1555
+ // (保存按钮禁用)而 promptDirty 为真(预览标成草稿),两处自相矛盾。
1556
+ const promptDirty = PROMPT_FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
1072
1557
  // 草稿指纹:仅在影响编译的字段真的变化时才重新预览
1073
1558
  const promptDraftKey = PROMPT_FIELD_KEYS.map((key) => String(localMap[key])).join('\u0000')
1074
1559
 
@@ -1079,7 +1564,7 @@ window.__ModuleLoader__.load({
1079
1564
  return out
1080
1565
  }
1081
1566
 
1082
- // 有未保存编辑时预览草稿(=保存后真正会注入的内容),否则预览已生效提示词
1567
+ // 有未保存编辑时预览草稿(=保存后真正会生效的内容),否则预览已生效提示词
1083
1568
  const loadPrompt = async () => {
1084
1569
  const asDraft = promptDirty
1085
1570
  setPromptLoading(true)
@@ -1101,43 +1586,31 @@ window.__ModuleLoader__.load({
1101
1586
  // 展开/收起只切状态;编译由下方 effect 统一驱动(唯一入口,避免重复请求)
1102
1587
  const togglePrompt = () => setShowPrompt((open) => !open)
1103
1588
 
1104
- const loadPersonas = async () => {
1105
- try {
1106
- const payload = await controller.fetchPersonas()
1107
- controller.store.update((s) => {
1108
- s.personas = payload.personas
1109
- s.activePersona = payload.activeName
1110
- })
1111
- } catch {
1112
- // 预设列表加载失败不打断主配置界面
1113
- }
1114
- }
1589
+ // 逻辑已下沉到 controller.loadPersonas(全局轮询与设置页共用一份实现)。
1590
+ const loadPersonas = () => controller.loadPersonas()
1115
1591
 
1116
- // 设置页打开后重新读取宿主配置,补齐 /soul 命令等外部写入路径。
1117
- // 表单 dirty 或正在保存时暂停,避免远端刷新覆盖用户未保存的输入。
1592
+ // 定时轮询已**上移到 apply()**(全局一份,与设置页是否挂载无关)——
1593
+ // 这里只负责告诉它「现在别刷」:有未保存编辑或正在保存时暂停,
1594
+ // 否则远端刷新会把用户正在改的值拽回去。
1595
+ //
1596
+ // 用 effect 而不是渲染期直接赋值:赋值要放在提交后(且卸载时必须复位,
1597
+ // 否则关掉设置页会永久暂停轮询 —— 那正是这次要修的 bug 的镜像)。
1118
1598
  React.useEffect(() => {
1119
- const refresh = () => {
1120
- if (dirty || saving) return
1121
- void controller.loadConfig()
1122
- void loadPersonas()
1123
- }
1124
- const onVisibilityChange = () => {
1125
- if (document.visibilityState === 'visible') refresh()
1126
- }
1127
- refresh()
1128
- window.addEventListener('focus', refresh)
1129
- document.addEventListener('visibilitychange', onVisibilityChange)
1130
- const timer = setInterval(refresh, 2000)
1131
- return () => {
1132
- window.removeEventListener('focus', refresh)
1133
- document.removeEventListener('visibilitychange', onVisibilityChange)
1134
- clearInterval(timer)
1135
- }
1599
+ controller.paused = dirty || saving
1600
+ return () => { controller.paused = false }
1136
1601
  }, [controller, dirty, saving])
1137
1602
 
1603
+ // 进入设置页时立即对齐一次宿主状态(补齐 /soul 命令等外部写入路径),
1604
+ // 不必等下一个 2s 轮询周期。
1605
+ React.useEffect(() => {
1606
+ if (!controller.canRefresh()) return
1607
+ void controller.loadConfig()
1608
+ void controller.loadPersonas()
1609
+ }, [controller])
1610
+
1138
1611
  // 提示词预览的编译入口(唯一)。
1139
1612
  // 展开时立即编译一次;此后只要影响编译的字段或脏状态变化,停止 400ms 再编译 —
1140
- // 边改边看保存后真正会注入的内容。
1613
+ // 边改边看保存后真正会生效的内容。
1141
1614
  // promptSettled 用来区分「刚展开」与「展开后字段又变了」:否则展开这一动作自身
1142
1615
  // 会先立即编译一次、再被防抖重复编译一次。
1143
1616
  // 依赖里刻意不放 loadPrompt(组件体内每次渲染都会重建函数,放进去会无限循环);
@@ -1161,34 +1634,47 @@ window.__ModuleLoader__.load({
1161
1634
  const payload = await controller.saveConfig(localMap)
1162
1635
  // saveConfig 失败时内部已记录 error 并返回 undefined:给出失败提示,而非误报「无变化」
1163
1636
  if (!payload) {
1164
- setToast({ text: t('toast.saveFailed'), kind: 'error' })
1637
+ showError(t('toast.saveFailed'))
1165
1638
  return
1166
1639
  }
1167
- setToast({
1168
- text: Array.isArray(payload.changed) && payload.changed.length > 0 ? t('toast.saved') : t('toast.noChanges'),
1169
- kind: 'success'
1170
- })
1640
+ // 成功提示走**内联绿字**(对齐宿主模型设置页的 savedNotice —— 见渲染处注释):
1641
+ // 有实际变化时报「已保存」;无变化时报「配置无变化」。
1642
+ showSavedNotice(
1643
+ Array.isArray(payload.changed) && payload.changed.length > 0
1644
+ ? t('notice.saved')
1645
+ : t('notice.noChanges')
1646
+ )
1171
1647
  // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1172
1648
  // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1173
1649
  }
1174
1650
 
1175
1651
  const handleReset = async () => {
1176
- await controller.resetConfig()
1177
- setToast({ text: t('toast.reset'), kind: 'success' })
1652
+ // resetConfig 失败时内部已记录 error 并返回 undefined。
1653
+ // 此前这里无条件报「已重置」——失败也显示成功,正好把「配置损坏时唯一的自救操作
1654
+ // 没成功」这件事藏了起来。
1655
+ const payload = await controller.resetConfig()
1656
+ if (!payload) {
1657
+ showError(t('toast.resetFailed'))
1658
+ return
1659
+ }
1660
+ showSavedNotice(payload.backupPath ? t('notice.resetRecovered') : t('notice.reset'))
1178
1661
  // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1179
1662
  // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1180
1663
  }
1181
1664
 
1665
+ // 存为预设:把「关于你/特质」里的**当前设置**存成一个新预设。
1666
+ // 名字来自命名弹层;成功后关闭弹层并清空输入。
1182
1667
  const handleSavePersona = async () => {
1183
1668
  const name = personaName.trim()
1184
1669
  if (!name) return
1185
1670
  try {
1186
1671
  await controller.savePersona(name)
1187
1672
  setPersonaName('')
1673
+ setShowSavePersona(false)
1188
1674
  await loadPersonas()
1189
- setToast({ text: t('toast.personaSaved'), kind: 'success' })
1675
+ showSavedNotice(t('notice.personaSaved', { name }))
1190
1676
  } catch (err) {
1191
- setToast({ text: messageOf(err), kind: 'error' })
1677
+ showError(messageOf(err))
1192
1678
  }
1193
1679
  }
1194
1680
 
@@ -1196,25 +1682,87 @@ window.__ModuleLoader__.load({
1196
1682
  try {
1197
1683
  const payload = await controller.usePersona(name)
1198
1684
  await loadPersonas()
1199
- setToast({ text: payload && payload.unchanged ? t('toast.personaUnchanged') : t('toast.personaUsed'), kind: 'success' })
1685
+ // 服务端回报了被丢弃的非法字段 ⇒ 预设只被部分应用,必须说出来而不是报「已应用」
1686
+ const invalid = Array.isArray(payload && payload.invalid) ? payload.invalid : []
1687
+ if (invalid.length > 0) {
1688
+ showError(t('toast.personaPartial', { fields: invalid.join(', ') }))
1689
+ } else {
1690
+ // 成功提示走内联绿字(对齐宿主 savedNotice);无变化也照报
1691
+ showSavedNotice(payload && payload.unchanged
1692
+ ? t('notice.personaUnchanged')
1693
+ : t('notice.personaUsed', { name }))
1694
+ }
1200
1695
  // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1201
1696
  // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1202
1697
  } catch (err) {
1203
- setToast({ text: messageOf(err), kind: 'error' })
1698
+ showError(messageOf(err))
1204
1699
  }
1205
1700
  }
1206
1701
 
1702
+ // 删除预设:**内联二次确认**,不用原生 window.confirm。
1703
+ //
1704
+ // 为什么换掉:原生模态框在 webview 里可能把文档焦点留在异常状态 —— 症状正是用户
1705
+ // 报的「删除后页面看得见却点不动、下拉框与输入框都无法操作」,而最小化再恢复
1706
+ // (触发 focus/visibilitychange)就变好。宿主自己的设置页也不用原生 confirm。
1707
+ //
1708
+ // 交互:第一次点「删除」→ 该按钮变成「确认删除?」且进入待确认态;再点一次才真删。
1709
+ // 期间点别处 / 切换卡片 / 超时(见下方 effect)都会复位,避免误删。
1207
1710
  const handleDeletePersona = async (name) => {
1208
- if (typeof globalThis.confirm === 'function' && !globalThis.confirm(t('personas.confirmDelete', { name }))) return
1711
+ if (pendingDelete !== name) {
1712
+ setPendingDelete(name)
1713
+ return
1714
+ }
1715
+ setPendingDelete(null)
1209
1716
  try {
1210
1717
  await controller.deletePersona(name)
1211
1718
  await loadPersonas()
1212
- setToast({ text: t('toast.personaDeleted'), kind: 'success' })
1719
+ showSavedNotice(t('notice.personaDeleted', { name }))
1213
1720
  } catch (err) {
1214
- setToast({ text: messageOf(err), kind: 'error' })
1721
+ showError(messageOf(err))
1215
1722
  }
1216
1723
  }
1217
1724
 
1725
+ // 二次确认的超时复位:进入待确认态后 5 秒内没有再次点击就取消。
1726
+ // 没有这道兜底,「确认删除?」会一直挂着 —— 用户过一会儿回来随手一点就真删了。
1727
+ React.useEffect(() => {
1728
+ if (pendingDelete === null) return
1729
+ const timer = setTimeout(() => setPendingDelete(null), 5000)
1730
+ return () => clearTimeout(timer)
1731
+ }, [pendingDelete])
1732
+
1733
+ // —— 卡背(详情面)用到的三个辅助 ——
1734
+ //
1735
+ // 列出「一个人设完整的六个维度」。顺序与设置页表单一致(风格 → 标题列表 →
1736
+ // 表情 → 表格 → 回复长度 → 语言),这样卡背读起来与设置页对得上。
1737
+ const PERSONA_DIM_KEYS = ['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language']
1738
+
1739
+ // 单个维度的「值」文案。刻意**不把与默认相同的项留空**:设置页里 trait 的默认值
1740
+ // 就是 'default',而卡背要回答的是「这个人设改了什么」—— 统一标成「默认」比留空
1741
+ // 更清楚(留空会让人以为渲染坏了)。
1742
+ // 语言维度复用 LANGUAGE_OPTIONS 的标签('中文' / 'English' 两种语言下同名,
1743
+ // 本来就是语言自称,无需翻译)。
1744
+ const personaDimLabel = (entry, dim) => {
1745
+ const row = entry || {}
1746
+ const value = row[dim]
1747
+ if (dim === 'language') {
1748
+ const opt = LANGUAGE_OPTIONS.find((o) => o.value === value)
1749
+ return opt ? opt.label : t('personas.dimDefault')
1750
+ }
1751
+ if (dim === 'style') {
1752
+ return STYLE_VALUES.includes(value) ? t(`style.${value}`) : t('personas.dimDefault')
1753
+ }
1754
+ return TRAIT_VALUES.includes(value) ? t(`trait.${dim}.${value}`) : t('personas.dimDefault')
1755
+ }
1756
+
1757
+ // 卡背的全文:这一次**不截断**(正面效果栏才截断)。
1758
+ // 预设没写自定义指令时给一句说明,而不是留空 —— 留空会让人以为渲染坏了。
1759
+ const personaFullText = (entry) => {
1760
+ const raw = typeof (entry || {}).customInstructions === 'string'
1761
+ ? entry.customInstructions.trim()
1762
+ : ''
1763
+ return raw === '' ? t('personas.noCustomInstructions') : raw
1764
+ }
1765
+
1218
1766
  const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
1219
1767
  const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
1220
1768
  const emojiOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.emoji.${value}`) }))
@@ -1229,10 +1777,47 @@ window.__ModuleLoader__.load({
1229
1777
  return aBuiltin - bBuiltin
1230
1778
  })
1231
1779
  : null
1780
+
1781
+ // 展开「人设预设」分组时,轮播自动定位到**当前生效**的那张,而不是永远停在第 1 张。
1782
+ //
1783
+ // 为什么需要:卡片一次只展示一张,若固定从第 1 张开始,用户点开分组后要翻好几下
1784
+ // 才能看到自己正在用的是哪个(内置项排在前面,自建/生效项往往在后面)。
1785
+ //
1786
+ // 三个边界都要处理:
1787
+ // ① **只在「展开」这一刻定位**,不在展开期间持续定位 —— 否则 2s 轮询刷新 personas
1788
+ // 会把用户手动翻到的位置拽回去。用 ref 记住「本次展开已定位过」。
1789
+ // ② **折叠时重置标记** —— 下次展开要重新定位(这正是需求本身)。
1790
+ // ③ **数据未就绪时不下手** —— personas 还在加载(null)时定位会误判成第 0 张,
1791
+ // 等数据到了、effect 依赖变化会自然补上(标记此时仍为 false)。
1792
+ const autoPositionedRef = React.useRef(false)
1793
+ React.useEffect(() => {
1794
+ if (openSection !== 'personas') {
1795
+ // 折叠状态:清掉标记,让下次展开能重新定位。
1796
+ //
1797
+ // 刻意**不在这里**清翻面态:折叠期间 body 不渲染,翻面态在 UI 上不可观测,
1798
+ // 清与不清行为等价(判断力验证证实:删掉它所有用例仍通过)。可观测的点是
1799
+ // 「重新展开」那一刻,由下方 autoPositionedRef 分支的 setFlippedName(null)
1800
+ // 承担 —— 职责归属唯一,避免两处重复同一件事、日后改动只更新一处。
1801
+ autoPositionedRef.current = false
1802
+ return
1803
+ }
1804
+ if (autoPositionedRef.current) return
1805
+ if (!personaNames || personaNames.length === 0) return
1806
+ // 当前无生效人设(或名字对不上任何一项)时退回第 0 张 —— 总要给个确定的落点
1807
+ const at = activePersona ? personaNames.indexOf(activePersona) : -1
1808
+ // 自动定位同样是一次「换一张看」:清掉翻面态,避免定位到的那张
1809
+ // 恰好是上次翻过面的卡而以背面出现
1810
+ setFlippedName(null)
1811
+ setPersonaIndex(at >= 0 ? at : 0)
1812
+ autoPositionedRef.current = true
1813
+ }, [openSection, personaNames, activePersona])
1232
1814
  const aboutFilled = [localNickname, localOccupation, localBio].filter(Boolean).length
1233
1815
  const aboutSummary = aboutFilled > 0 ? `${aboutFilled}/3 ${t('accordion.filled')}` : t('accordion.empty')
1234
1816
  const traitsSummary = t(`style.${localStyle}`)
1235
- const personasSummary = personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`
1817
+ // 失败时摘要也要区分(折叠状态下用户只看得到这一行)
1818
+ const personasSummary = personasError
1819
+ ? t('accordion.loadFailed')
1820
+ : (personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`)
1236
1821
  const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
1237
1822
  const promptSummary = promptText.length > 0
1238
1823
  ? t(promptIsDraft ? 'prompt.summaryDraft' : 'prompt.summaryChars', { n: String(promptText.length) })
@@ -1241,15 +1826,15 @@ window.__ModuleLoader__.load({
1241
1826
  setOpenSection(current => current === section ? null : section)
1242
1827
  }
1243
1828
 
1244
- // 预设行摘要:风格 + 昵称 + 所有「偏离默认值」的维度。
1245
- // 内置预设刻意不声明昵称等用户自有信息,所以它们只显示风格与维度差异——
1246
- // 这恰好也是比较两个预设时最需要看到的信息。
1829
+ // 预设行摘要:风格 + 所有「偏离默认值」的人设维度。
1830
+ // 预设只描述 Agent 的人格,不含「关于你」(昵称 / 职业 / 介绍)—— 那是使用者
1831
+ // 的身份信息,不随预设切换。所以摘要里不再出现昵称,剩下的恰好也是比较两个
1832
+ // 预设时最需要看到的信息。
1247
1833
  const personaRowMeta = (entry) => {
1248
1834
  const row = entry || {}
1249
1835
  const parts = []
1250
1836
  if (STYLE_VALUES.includes(row.style)) parts.push(t(`style.${row.style}`))
1251
1837
  else if (row.style) parts.push(row.style)
1252
- if (row.nickname) parts.push(row.nickname)
1253
1838
  for (const [field, allowed] of Object.entries(META_FIELD_VALUES)) {
1254
1839
  const value = row[field]
1255
1840
  if (!value || value === META_FIELD_DEFAULTS[field]) continue
@@ -1259,6 +1844,20 @@ window.__ModuleLoader__.load({
1259
1844
  return parts.join(' · ')
1260
1845
  }
1261
1846
 
1847
+ // 卡牌效果栏文本:优先用预设的 customInstructions(它是人格差异的主要载体),
1848
+ // 没有则退回维度摘要。为什么取 customInstructions 而不是摘要:摘要只说
1849
+ // 「专业严谨 · 默认」这类档位,而卡牌需要一句话说清「这个预设会怎么回答」。
1850
+ //
1851
+ // 截断到第一句或 60 字:卡面空间有限,CSS 侧还有 3 行 line-clamp 兜底。
1852
+ const personaEffectText = (entry) => {
1853
+ const raw = typeof (entry || {}).customInstructions === 'string' ? entry.customInstructions.trim() : ''
1854
+ if (raw === '') return personaRowMeta(entry)
1855
+ // 按中文句号/分号/换行切第一句;无标点时整段交给 CSS 截断
1856
+ const firstSentence = raw.split(/[。;;\n]/)[0] || raw
1857
+ const text = firstSentence.length > 60 ? `${firstSentence.slice(0, 60)}…` : firstSentence
1858
+ return text
1859
+ }
1860
+
1262
1861
  return e('div', { className: 'soul-section' },
1263
1862
  e('div', { className: 'soul-title-row' },
1264
1863
  e('h3', null, t('settings.title')),
@@ -1421,40 +2020,277 @@ window.__ModuleLoader__.load({
1421
2020
  onToggle: toggleSection('personas')
1422
2021
  },
1423
2022
  e('div', { className: 'soul-field' },
1424
- personaNames === null
2023
+ // 加载失败优先于「加载中」:否则失败时界面永远停在加载态,与「还没有预设」无从区分
2024
+ personasError
2025
+ ? e('div', { className: 'soul-error' }, t('personas.loadFailed', { reason: personasError }))
2026
+ : (personaNames === null
1425
2027
  ? e('div', { className: 'soul-status' }, t('status.loading'))
1426
2028
  : (personaNames.length === 0
1427
2029
  ? e('div', { className: 'soul-status' }, t('personas.empty'))
1428
- : e(Fragment, null,
1429
- ...personaNames.map((name) => {
2030
+ : (() => {
2031
+ // —— 卡片堆叠轮播 ——
2032
+ // 一次只展示一张,左右各留一张做层叠露边。为什么用绝对定位 + transform:
2033
+ // 三张卡片共存于同一堆叠容器内,若走文档流则容器高度会随张数变化而跳动。
2034
+ const total = personaNames.length
2035
+ // 夹取索引:预设可能被删除、或列表刷新后变短,索引会越界。
2036
+ // 刻意不在 effect 里回写 state —— 那会多一次渲染,且「删除后该看哪张」
2037
+ // 本就有歧义,渲染时夹取即可保证不越界。
2038
+ const index = Math.min(Math.max(personaIndex, 0), total - 1)
2039
+ // 翻页时**同时清掉翻面态**:翻面是「看这张卡背面」的临时阅读动作,
2040
+ // 翻页是「换一张看」—— 用户的注意力已经重置。若记住它,切回来时
2041
+ // 这张卡会以背面出现,看起来像「它默认就是背面」(用户实测反馈)。
2042
+ const go = (next) => {
2043
+ setFlippedName(null)
2044
+ setPersonaIndex(Math.min(Math.max(next, 0), total - 1))
2045
+ }
2046
+ const renderCard = (name, offset) => {
1430
2047
  const entry = personas[name] || {}
1431
- // 内置预设不提供删除(后端也会拒绝),在 actions 区原删除按钮的
1432
- // 位置显示「内置」标记,行宽因此不会跳动。
1433
2048
  const builtin = !!entry.builtin
1434
- return e('div', { className: 'soul-persona-row', key: name },
1435
- e('span', { className: 'soul-persona-name' }, name === activePersona ? `★ ${name}` : name),
1436
- e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
1437
- e('span', { className: 'soul-persona-actions' },
1438
- builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin')),
1439
- e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
1440
- !builtin && e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
2049
+ const current = offset === 0
2050
+ // 只有当前卡能翻面(叠层两张不参与交互)
2051
+ const flipped = current && flippedName === name
2052
+ return e('div', {
2053
+ key: name,
2054
+ className: 'soul-persona-card',
2055
+ 'data-offset': String(offset),
2056
+ 'data-current': current ? 'true' : 'false',
2057
+ 'data-builtin': builtin ? 'true' : 'false',
2058
+ 'data-flipped': flipped ? 'true' : 'false',
2059
+ style: {
2060
+ // 叠层几何:当前张居中不动;±1 两张向左/右偏移并缩小。
2061
+ // 竖版卡牌宽 260px,偏移要足够大才能看出「左右两张卡」,
2062
+ // 太小会与当前卡重叠成一坨。92px 偏移 + 6% 缩小,叠层露出的
2063
+ // 是一条完整的卡边(约 40px 可见宽度)。
2064
+ //
2065
+ // ⚠️ 这个 transform 归**叠层偏移**所有,绝不可把翻转的
2066
+ // rotateY 写进来 —— 两者合成同一个 transform 字符串会让
2067
+ // 偏移几何变脆。翻转交给中间的 .soul-persona-flipper
2068
+ // (它有自己的 rotateY 0↔180,见 CSS),职责分离:
2069
+ // 卡片管「叠层位置」,flipper 管「翻到哪一面」。
2070
+ transform: `translateX(${offset * 92}px) translateY(${Math.abs(offset) * 10}px) scale(${1 - Math.abs(offset) * 0.06})`,
2071
+ zIndex: String(10 - Math.abs(offset)),
2072
+ // 叠层卡片压暗一点,形成前后景深(不能用 filter:brightness,
2073
+ // 那会连带把文字变灰、看起来像禁用态)
2074
+ opacity: offset === 0 ? '1' : '0.55'
2075
+ },
2076
+ // 非当前卡片不参与交互、也不让读屏念到(否则会念出重复内容)
2077
+ 'aria-hidden': current ? undefined : 'true',
2078
+ ...(current ? {} : { onClick: () => go(index + offset) })
2079
+ },
2080
+ // —— 3D 翻转容器(中间层)——
2081
+ //
2082
+ // 为什么需要这一层:卡片的 transform 归**叠层偏移**所有
2083
+ // (translateX/translateY/scale 内联在卡片上),翻转的
2084
+ // rotateY(180deg) 必须落在**另一层**,否则两者要合成同一个
2085
+ // transform 字符串,偏移几何会随之变脆。
2086
+ // 现在职责分离:卡片管「叠层位置」,flipper 管「翻到哪一面」。
2087
+ e('div', { className: 'soul-persona-flipper', 'aria-hidden': undefined },
2088
+ e('div', {
2089
+ className: 'soul-persona-face soul-persona-face-front',
2090
+ // 翻到背面时正面要彻底退出交互与读屏(否则 Tab 会落到看不见的按钮上)
2091
+ 'aria-hidden': flipped ? 'true' : undefined,
2092
+ ...(flipped ? { inert: '' } : {}),
2093
+ // 整面点击翻到卡背(用户期望:任意处点击都能翻面)。
2094
+ // ⚠️ 只对【当前卡】生效(current 条件):叠层侧边卡的点击
2095
+ // 必须留给卡片的 go() 翻页 —— 否则点击先冒泡到 face 的
2096
+ // setFlippedName(侧边卡名),再冒泡到卡片的 go() 翻页,
2097
+ // 翻页后它成了当前卡 ⇒ flippedName === 它 ⇒ 直接以背面
2098
+ // 姿态出现(用户实测:切下一个人设时自动翻面了)。
2099
+ // 面上的控件用 stopPropagation 自己接管:「使用」点了就该用,
2100
+ // 不能顺带翻面;「详情」的语义本来就是翻面,不拦(双保险)。
2101
+ // cursor 由 CSS 给整面(含插画区),提示可点。
2102
+ onClick: (current && !flipped) ? () => setFlippedName(name) : undefined
2103
+ },
2104
+ // 插画区(卡面主视觉):**所有卡片都出图**。
2105
+ //
2106
+ // 内置预设:宿主按人设名返回 assets/personas/<名>.webp;
2107
+ // 自建预设:宿主返回专用默认贴图(调色板,与官方的鲸鱼区分
2108
+ // 「你自己配的」与「官方提供的」)。本版只做样式调整 ——
2109
+ // 允许用户上传本地图片作插画是后续版本功能。
2110
+ // 仍留 onError 兜底隐藏:路由不可用时退化为渐变底而非破图。
2111
+ e('div', { className: 'soul-persona-art' },
2112
+ e('img', {
2113
+ className: 'soul-persona-art-img',
2114
+ src: new URL(`/api/soul/persona-art/${encodeURIComponent(name)}`, hostBase()).href,
2115
+ alt: '',
2116
+ loading: 'lazy',
2117
+ decoding: 'async',
2118
+ draggable: 'false',
2119
+ onError: (ev) => { ev.target.style.display = 'none' }
2120
+ }),
2121
+ // 渐变遮罩:让压在图上的名称/摘要有足够对比度
2122
+ e('div', { className: 'soul-persona-art-veil', 'aria-hidden': true }),
2123
+ builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin')),
2124
+ // 位置指示只出现在当前卡片上:左右露边的卡片若也显示「i / n」,
2125
+ // 三张会同时出现同一个计数,看起来像显示错误。
2126
+ current && e('span', { className: 'soul-persona-card-pos' }, `${index + 1} / ${total}`),
2127
+ // 名称与摘要压在图上
2128
+ e('div', { className: 'soul-persona-art-title' },
2129
+ e('span', { className: 'soul-persona-name' },
2130
+ e('span', { className: 'soul-persona-label' }, name === activePersona ? `★ ${name}` : name)
2131
+ ),
2132
+ e('div', { className: 'soul-persona-meta' }, personaRowMeta(entry))
2133
+ )
2134
+ ),
2135
+ e('div', { className: 'soul-persona-body' },
2136
+ // 效果栏:卡牌的核心信息(这个预设会怎么回答)
2137
+ e('div', { className: 'soul-persona-eff' }, personaEffectText(entry)),
2138
+ e('div', { className: 'soul-persona-actions' },
2139
+ // 当前生效的人设:按钮文字变「使用中」+ 描边式样式(区别于实心主色),
2140
+ // 一眼就能看出哪张卡是当前配置。名称前的 ★ 保留,双重标识互相强化。
2141
+ e('button', {
2142
+ type: 'button',
2143
+ className: name === activePersona ? 'soul-persona-action soul-persona-active' : 'soul-persona-action',
2144
+ onClick: (ev) => {
2145
+ // 整面点击=翻面;点「使用」只想用预设,不能顺带翻面
2146
+ ev?.stopPropagation()
2147
+ void handleUsePersona(name)
2148
+ }
2149
+ }, name === activePersona ? t('personas.active') : t('personas.use')),
2150
+ // 「删除」已移到**卡背**(管理语境,与「返回」并排)——
2151
+ // 此前夹在「使用/详情」中间的 43px 窄条观感怪异(用户反馈),
2152
+ // 且自建卡三钮 vs 内置卡两钮导致两类卡观感不齐。
2153
+ // 现在两类卡正面都是「使用 + 详情」,完全一致。
2154
+ // 「详情」只在当前卡上出现(叠层两张不给点)。
2155
+ current && e('button', {
2156
+ type: 'button',
2157
+ className: 'soul-persona-action soul-persona-details',
2158
+ title: t('personas.details'),
2159
+ onClick: () => setFlippedName(name)
2160
+ }, t('personas.details'))
2161
+ )
2162
+ )
2163
+ ),
2164
+ // —— 背面(详情)——
2165
+ // 只读信息面:列全部维度 + customInstructions 全文。
2166
+ // 不放「使用/删除」按钮:这是阅读态,且实测卡背内容已占 370/372px,
2167
+ // 再放 36px 的按钮必然溢出(按钮留在正面即可,翻面后仍可返回)。
2168
+ e('div', {
2169
+ className: 'soul-persona-face soul-persona-face-back',
2170
+ 'aria-hidden': flipped ? undefined : 'true',
2171
+ ...(flipped ? {} : { inert: '' }),
2172
+ // 整面点击返回正面(与正面「整面点击翻到背面」对称)。
2173
+ //
2174
+ // ⚠️ 必须排除「拖选文字」:卡背有 339 字可滚动全文,用户很可能
2175
+ // 拖选复制。拖选结束时会触发一次 click,若不判断就等于「一选就
2176
+ // 翻回去」。判据用 window.getSelection() 是否落在卡背内 ——
2177
+ // 有非折叠选区就说明这是一次选择操作,不返回。
2178
+ // 另外点击「删除」按钮时靠它的 stopPropagation 挡住(见下),
2179
+ // 否则删除确认会被返回动作打断。
2180
+ onClick: () => {
2181
+ if (!flipped) return
2182
+ const sel = globalThis.getSelection?.()
2183
+ if (sel && !sel.isCollapsed && sel.toString().trim() !== '') return
2184
+ setFlippedName(null)
2185
+ }
2186
+ },
2187
+ e('div', { className: 'soul-persona-back-head' },
2188
+ e('span', { className: 'soul-persona-back-name' }, name),
2189
+ builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin'))
2190
+ ),
2191
+ e('div', { className: 'soul-persona-dims' },
2192
+ ...PERSONA_DIM_KEYS.map((dim) => e('div', { className: 'soul-persona-dim', key: dim },
2193
+ e('span', null, t(`field.${dim}`)),
2194
+ e('b', null, personaDimLabel(entry, dim))
2195
+ ))
2196
+ ),
2197
+ e('div', { className: 'soul-persona-back-label' }, t('personas.customInstructions')),
2198
+ e('div', { className: 'soul-persona-fulltext' }, personaFullText(entry)),
2199
+ // 底部按钮行:「返回」+「删除」(仅自建卡)。
2200
+ // 「删除」从正面移到这里(用户反馈正面三钮拥挤、夹缝观感差):
2201
+ // 背面是「查看 / 管理」语境,删除放这里语义顺;且底部本就是
2202
+ // 一行按钮,并排进来零高度成本。二次确认(armed 态 + 5s 超时)
2203
+ // 逻辑不变。
2204
+ e('div', { className: 'soul-persona-back-actions' },
2205
+ e('button', {
2206
+ type: 'button',
2207
+ className: 'soul-persona-action soul-persona-back-btn',
2208
+ // 整面点击=返回;「返回」自己就是返回,stopPropagation 只为
2209
+ // 语义清晰(避免同一动作触发两次 setState)
2210
+ onClick: (ev) => {
2211
+ ev?.stopPropagation()
2212
+ setFlippedName(null)
2213
+ }
2214
+ }, t('personas.flipBack')),
2215
+ !builtin && e('button', {
2216
+ type: 'button',
2217
+ className: pendingDelete === name
2218
+ ? 'soul-persona-action soul-persona-danger soul-persona-danger-armed'
2219
+ : 'soul-persona-action soul-persona-danger soul-persona-back-del',
2220
+ 'data-armed': pendingDelete === name ? 'true' : 'false',
2221
+ title: t('personas.confirmDelete', { name }),
2222
+ // 必须 stopPropagation:卡背整面点击=返回,
2223
+ // 不挡住的话「确认删除」会被返回动作打断(翻回正面、看不到提示)
2224
+ onClick: (ev) => {
2225
+ ev?.stopPropagation()
2226
+ void handleDeletePersona(name)
2227
+ }
2228
+ }, pendingDelete === name ? t('personas.confirmDeleteShort') : t('personas.delete'))
2229
+ )
1441
2230
  )
2231
+ ) // ← 关闭 .soul-persona-flipper
2232
+ )
2233
+ }
2234
+ const cards = []
2235
+ for (let offset = -1; offset <= 1; offset++) {
2236
+ const at = index + offset
2237
+ if (at < 0 || at >= total) continue
2238
+ cards.push(renderCard(personaNames[at], offset))
2239
+ }
2240
+ return e('div', { className: 'soul-persona-carousel' },
2241
+ e('div', { className: 'soul-persona-stack' }, ...cards),
2242
+ // 翻页 + 位置指示:一次只见一张,就必须给出「共几张 / 现在第几张」
2243
+ // 与可点的翻页入口 —— 列表本可一眼扫完,这几处是对该损失的补偿。
2244
+ e('div', { className: 'soul-persona-nav' },
2245
+ e('button', {
2246
+ type: 'button',
2247
+ className: 'soul-persona-navbtn',
2248
+ 'data-dir': 'prev',
2249
+ disabled: index === 0,
2250
+ 'aria-label': t('personas.prev'),
2251
+ onClick: () => go(index - 1)
2252
+ }, '‹'),
2253
+ e('div', { className: 'soul-persona-dots', role: 'tablist' },
2254
+ ...personaNames.map((n, i) => e('button', {
2255
+ key: n,
2256
+ type: 'button',
2257
+ role: 'tab',
2258
+ className: 'soul-persona-dot',
2259
+ 'data-active': i === index ? 'true' : 'false',
2260
+ 'aria-selected': i === index ? 'true' : 'false',
2261
+ 'aria-label': `${n}(${i + 1}/${total})`,
2262
+ onClick: () => go(i)
2263
+ }))
2264
+ ),
2265
+ e('button', {
2266
+ type: 'button',
2267
+ className: 'soul-persona-navbtn',
2268
+ 'data-dir': 'next',
2269
+ disabled: index === total - 1,
2270
+ 'aria-label': t('personas.next'),
2271
+ onClick: () => go(index + 1)
2272
+ }, '›')
1442
2273
  )
1443
- })
1444
- ))
2274
+ )
2275
+ })())
2276
+ )
1445
2277
  ),
1446
- e('div', { className: 'soul-field soul-persona-save' },
1447
- e('input', {
1448
- type: 'text',
1449
- value: personaName,
1450
- maxLength: 30,
1451
- onChange: (ev) => setPersonaName(ev.target.value),
1452
- placeholder: t('personas.savePlaceholder')
1453
- }),
1454
- e(ui.Button, {
1455
- onClick: handleSavePersona,
1456
- disabled: saving || !personaName.trim()
1457
- }, t('personas.save'))
2278
+ // —— 「已偏离任何预设」的出口 ——
2279
+ //
2280
+ // 每个内置预设现在都声明全部 6 个人格维度,所以**改任何特质都会丢 ★**
2281
+ // (不再匹配任何预设)。这是个诚实但**没有出路**的状态:用户刚调出来的
2282
+ // 这套配置无处安放。这里给一个明确落点:一句话说明 + 直接打开命名弹层存为新预设。
2283
+ //
2284
+ // 判据:activePersona === null(不匹配任何预设)且 personas 已就绪
2285
+ // (加载中/失败不算偏离)。全部为默认值时命中的是「默认」预设,
2286
+ // activeName 不为 null,不会走到这里。
2287
+ (activePersona === null && personaNames !== null && !personasError) && e('div', { className: 'soul-drift' },
2288
+ e('span', { className: 'soul-drift-text' }, t('personas.drift')),
2289
+ e('button', {
2290
+ type: 'button',
2291
+ className: 'soul-drift-action',
2292
+ onClick: () => { setPersonaName(''); setShowSavePersona(true) }
2293
+ }, t('personas.driftAction'))
1458
2294
  ),
1459
2295
  e('div', { className: 'soul-status' }, t('personas.hint'))
1460
2296
  ),
@@ -1542,9 +2378,10 @@ window.__ModuleLoader__.load({
1542
2378
  )
1543
2379
  ),
1544
2380
  e('div', { className: 'soul-field' },
1545
- e('label', null, t('trail.preview')),
2381
+ // 预览示例不是表单控件,没有可关联的 id —— 用 label 会是「指向空的标签」,
2382
+ // 所以改用同风格的 div(样式见 .soul-field-label)
2383
+ e('div', { className: 'soul-field-label' }, t('trail.preview')),
1546
2384
  e(SoulTrailPreview, {
1547
- enabled: localTrailEnabled,
1548
2385
  color: localTrailColor,
1549
2386
  speed: localTrailSpeed,
1550
2387
  width: localTrailWidth,
@@ -1570,7 +2407,7 @@ window.__ModuleLoader__.load({
1570
2407
  e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
1571
2408
  e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) })),
1572
2409
  promptInvalid.length > 0 && e('div', { className: 'soul-error' },
1573
- t('prompt.invalid', { fields: promptInvalid.join('、') }))
2410
+ t('prompt.invalid', { fields: promptInvalid.join(t('common.listSep')) }))
1574
2411
  ))
1575
2412
  ),
1576
2413
 
@@ -1582,20 +2419,92 @@ window.__ModuleLoader__.load({
1582
2419
  onClick: handleSave,
1583
2420
  disabled: saving || !dirty
1584
2421
  }, saving ? t('button.saving') : t('button.save')),
2422
+ // 方案 A:「保存当前为预设」从预设列表移到这里,与「保存设置」并列为同级动作。
2423
+ // 两个保存动作放在一起,差异一眼可见(就地生效 vs 另存一份),
2424
+ // 且按钮就贴在刚编辑过的表单旁 ⇒ 用户自然理解「存的是我刚才编的」。
2425
+ e(ui.Button, {
2426
+ onClick: () => { setPersonaName(''); setShowSavePersona(true) },
2427
+ disabled: saving
2428
+ }, t('personas.saveAs')),
1585
2429
  e(ui.Button, {
1586
2430
  onClick: handleReset,
1587
2431
  disabled: saving
1588
2432
  }, t('button.reset'))
1589
2433
  ),
1590
2434
 
2435
+ // ——「存为新预设」命名弹层 ——
2436
+ // 点开才问名字(而不是原先「先填名字、按钮才可点」的别扭顺序),
2437
+ // 并**明确列出会保存什么 / 不会保存什么** —— 这直接消灭了
2438
+ // 「不知道到底存了哪些值」这个困惑。
2439
+ showSavePersona && e('div', {
2440
+ className: 'soul-modal-mask',
2441
+ role: 'presentation',
2442
+ onClick: () => { setShowSavePersona(false); setPersonaName('') }
2443
+ },
2444
+ e('div', {
2445
+ className: 'soul-modal',
2446
+ role: 'dialog',
2447
+ 'aria-modal': 'true',
2448
+ 'aria-label': t('personas.saveAs'),
2449
+ onClick: (ev) => ev.stopPropagation()
2450
+ },
2451
+ e('div', { className: 'soul-modal-title' }, t('personas.saveAs')),
2452
+ e('div', { className: 'soul-field' },
2453
+ e('label', { htmlFor: 'soul-new-persona-name' }, t('personas.saveNameLabel')),
2454
+ e('input', {
2455
+ id: 'soul-new-persona-name',
2456
+ type: 'text',
2457
+ value: personaName,
2458
+ maxLength: 30,
2459
+ placeholder: t('personas.savePlaceholder'),
2460
+ onChange: (ev) => setPersonaName(ev.target.value),
2461
+ onKeyDown: (ev) => {
2462
+ if (ev.key === 'Enter' && personaName.trim() && !saving) void handleSavePersona()
2463
+ if (ev.key === 'Escape') { setShowSavePersona(false); setPersonaName('') }
2464
+ }
2465
+ })
2466
+ ),
2467
+ e('div', { className: 'soul-modal-note' },
2468
+ e('div', null, t('personas.saveCovers')),
2469
+ e('div', { className: 'soul-modal-note-excl' }, t('personas.saveExcludes'))
2470
+ ),
2471
+ e('div', { className: 'soul-modal-actions' },
2472
+ e(ui.Button, {
2473
+ onClick: () => { setShowSavePersona(false); setPersonaName('') }
2474
+ }, t('button.cancel')),
2475
+ e(ui.Button, {
2476
+ variant: 'primary',
2477
+ onClick: handleSavePersona,
2478
+ disabled: saving || !personaName.trim()
2479
+ }, saving ? t('button.saving') : t('personas.saveConfirm'))
2480
+ )
2481
+ )
2482
+ ),
2483
+
1591
2484
  dirty && !saving && e('div', { className: 'soul-status soul-dirty' }, t('status.unsaved')),
1592
2485
 
1593
- toast && e('div', {
1594
- className: `soul-toast ${toast.kind === 'error' ? 'soul-toast-error' : 'soul-toast-success'}`,
1595
- style: {
1596
- animation: 'fadeInOut 2s ease-in-out'
1597
- }
1598
- }, toast.text)
2486
+ // —— 内联结果提示(对齐宿主模型设置页的全局形态)——
2487
+ //
2488
+ // 查证(dsh-client-ui-settings-models/lib/client.js):宿主设置页**从不使用
2489
+ // toast**(Toast 引用 = 0),成功与失败都是设置页内的**一行内联文字**:
2490
+ // 成功 → .savedNotice(state-success-primary 绿、12px/18px、role:status +
2491
+ // aria-live:polite),位于标题/简介之后、分组列表之前;
2492
+ // 失败 → .error(state-error-primary 红、12px/18px、role:alert),
2493
+ // 就地出现在问题发生处(行内/卡片顶部),共 11 处使用。
2494
+ // 两者同字号同行高,仅红绿与 role 之差 —— 完全对称。
2495
+ //
2496
+ // 因此这里对称承载:绿字(成功)/ 红字(失败),同一位置、互斥显示
2497
+ // (失败压过成功 —— 用户要看到最新一次操作的结果)。两者都不自动消失:
2498
+ // 绿字在开始编辑(dirty)时清除,红字在下一次操作开始时被替换或清除。
2499
+ errorNotice && e('p', {
2500
+ className: 'soul-error-notice',
2501
+ role: 'alert'
2502
+ }, errorNotice),
2503
+ !errorNotice && savedNotice && e('p', {
2504
+ className: 'soul-saved-notice',
2505
+ role: 'status',
2506
+ 'aria-live': 'polite'
2507
+ }, savedNotice)
1599
2508
  )
1600
2509
  }
1601
2510
 
@@ -1605,33 +2514,29 @@ window.__ModuleLoader__.load({
1605
2514
 
1606
2515
  // 设置页内的实时示例:与线上光轨共用同一渲染层、同一挂载点解析(resolveTrailMountPoint)
1607
2516
  // 与同一份 CSS,因此「示例所见」即「回复时所得」;颜色/速度/粗细随表单实时联动。
2517
+ //
2518
+ // 本组件**只在光轨开启时**被渲染(见调用处的 `localTrailEnabled &&`),所以它没有
2519
+ // 「关闭」这一态:此前它接了一个 `enabled` prop 并写了 off 分支,那是永不执行的死
2520
+ // 代码(且 `data-soul-trail="off"` 会连带影响 CSS 选择器),现在去掉。
1608
2521
  function SoulTrailPreview(props) {
1609
2522
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
1610
2523
  const hostRef = React.useRef(null)
1611
2524
  const anchorRef = React.useRef(null)
1612
- const mountedRef = React.useRef(null)
1613
2525
 
1614
2526
  React.useEffect(() => {
1615
2527
  const host = hostRef.current
1616
2528
  if (!host) return undefined
1617
2529
  // 与线上 SoulTrail 同一条路径:解析出的挂载点即示例内的零高度锚点 .soul-trail-anchor
1618
2530
  const mounted = mountTrailRing(host, resolveTrailMountPoint(host, anchorRef.current))
1619
- mountedRef.current = mounted
1620
- return () => {
1621
- mountedRef.current = null
1622
- mounted.unmount()
1623
- }
2531
+ // 环的内联样式以 display:none 起(见 mountTrailRing),必须显式显形一次
2532
+ mounted.setVisible(true)
2533
+ return () => mounted.unmount()
1624
2534
  }, [])
1625
2535
 
1626
- React.useEffect(() => {
1627
- const mounted = mountedRef.current
1628
- if (mounted) mounted.setVisible(props.enabled !== false)
1629
- }, [props.enabled])
1630
-
1631
2536
  return e('div', {
1632
2537
  ref: hostRef,
1633
2538
  className: 'soul-trail-preview',
1634
- 'data-soul-trail': props.enabled ? 'on' : 'off',
2539
+ 'data-soul-trail': 'on',
1635
2540
  'data-soul-trail-speed': props.speed || 'slow',
1636
2541
  'data-soul-trail-width': props.width || 'thin',
1637
2542
  style: { '--soul-trail-color': safeTrailColor(props.color) }
@@ -1716,6 +2621,36 @@ window.__ModuleLoader__.load({
1716
2621
  }, 'dsh-soul: browser lifecycle')
1717
2622
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-soul: dictionaries')
1718
2623
 
2624
+ // —— 全局轮询:让**不打开设置页**时底栏也能跟上宿主侧的变化 ——
2625
+ //
2626
+ // 修掉的 bug:用户用 /soul confirm 确认人设变更后,宿主已写盘(配置文件正确),
2627
+ // 但底栏「个性化已启用 · 人设名」仍显示旧值。根因是三条刷新路径
2628
+ // (setInterval / focus / visibilitychange)**全部注册在 SoulSettings 组件内**,
2629
+ // 设置页没挂载时它们都不存在 ⇒ 底栏读的 store 无人刷新。
2630
+ //
2631
+ // 底栏(conversation.input.left)是常驻组件,它的数据链路不该依赖另一个
2632
+ // 组件的生命周期。放到装配层后:注册一次、全生命周期有效,且天然只有一份
2633
+ // 定时器(不会与设置页的重复 ⇒ 请求翻倍)。
2634
+ ctx.effect(() => {
2635
+ const refresh = () => {
2636
+ // 页面不可见时不轮询:后台标签页不必发请求
2637
+ if (document.visibilityState !== 'visible') return
2638
+ // 有未保存编辑时暂停(由设置页置位)—— 否则会把用户正在改的值拽回去
2639
+ if (!controller.canRefresh()) return
2640
+ void controller.loadConfig()
2641
+ void controller.loadPersonas()
2642
+ }
2643
+ refresh()
2644
+ window.addEventListener('focus', refresh)
2645
+ document.addEventListener('visibilitychange', refresh)
2646
+ const timer = setInterval(refresh, 2000)
2647
+ return () => {
2648
+ window.removeEventListener('focus', refresh)
2649
+ document.removeEventListener('visibilitychange', refresh)
2650
+ clearInterval(timer)
2651
+ }
2652
+ }, 'dsh-soul: global refresh polling')
2653
+
1719
2654
  // 暗色适配说明:原生 <select> 的弹出选项列表由浏览器按 color-scheme 渲染。
1720
2655
  // 宿主主题呈现器会把 html 的 color-scheme 与 body[data-ds-dark-theme] 随主题
1721
2656
  // 投影到 document(见 dsh-client-ui-layout ThemePresenter),因此这里用纯 CSS
@@ -1799,6 +2734,9 @@ window.__ModuleLoader__.load({
1799
2734
 
1800
2735
  const registerSettingsNavIcon = () => {
1801
2736
  let disposed = false
2737
+ // 改画前记下宿主 svg 的原始子节点:此前卸载时只清 marker 属性、不还原内容,
2738
+ // 于是禁用插件 / HMR 之后导航栏仍显示插件图标,直到壳层重建那个按钮。
2739
+ const originalIcons = new Map()
1802
2740
 
1803
2741
  const sync = () => {
1804
2742
  if (disposed) return
@@ -1817,6 +2755,7 @@ window.__ModuleLoader__.load({
1817
2755
  if (existingIcon) {
1818
2756
  // 标记已替换;React 重建按钮时标记随之消失,会重新画一次
1819
2757
  button.setAttribute(SOUL_ICON_MARKER, 'true')
2758
+ originalIcons.set(existingIcon, Array.from(existingIcon.childNodes))
1820
2759
  paintSoulNavIcon(existingIcon)
1821
2760
  }
1822
2761
  } else {
@@ -1827,14 +2766,24 @@ window.__ModuleLoader__.load({
1827
2766
 
1828
2767
  // 延迟执行初始同步,等待设置页面渲染完成
1829
2768
  let timer = setTimeout(sync, 500)
2769
+ // 最长等待:防抖只该合并「一连串变化」,不该被无限推迟 —— 会话流式输出时
2770
+ // body 每帧都在变,纯 100ms 防抖会让 sync 永远排不到(图标一直不替换)。
2771
+ const MAX_WAIT_MS = 600
2772
+ let pendingSince = 0
1830
2773
 
1831
2774
  // 监听 DOM 变化(只监听子节点添加,不监听所有变化)
1832
2775
  const observer = new MutationObserver(() => {
1833
2776
  // 防抖:每次变化都重置计时器,停止变化 100ms 后执行
1834
2777
  // (修复:此前新计时器未赋回 timer,clearTimeout 永远只清除首个 500ms
1835
2778
  // 计时器,防抖实际失效,每次 DOM 变化都会调度一次 sync)
2779
+ const now = Date.now()
2780
+ if (!pendingSince) pendingSince = now
1836
2781
  clearTimeout(timer)
1837
- timer = setTimeout(sync, 100)
2782
+ const waited = now - pendingSince
2783
+ timer = setTimeout(() => {
2784
+ pendingSince = 0
2785
+ sync()
2786
+ }, waited >= MAX_WAIT_MS ? 0 : 100)
1838
2787
  })
1839
2788
 
1840
2789
  observer.observe(document.body, {
@@ -1846,6 +2795,11 @@ window.__ModuleLoader__.load({
1846
2795
  disposed = true
1847
2796
  clearTimeout(timer)
1848
2797
  observer.disconnect()
2798
+ // 先还原图标内容,再清 marker:顺序反了会留下「无标记但已被改画」的节点
2799
+ for (const [svg, nodes] of originalIcons) {
2800
+ if (svg && typeof svg.replaceChildren === 'function') svg.replaceChildren(...nodes)
2801
+ }
2802
+ originalIcons.clear()
1849
2803
  document.querySelectorAll(`[${SOUL_NAV_MARKER}]`).forEach((element) => {
1850
2804
  element.removeAttribute(SOUL_NAV_MARKER)
1851
2805
  })
@@ -1872,6 +2826,7 @@ window.__ModuleLoader__.load({
1872
2826
  })
1873
2827
  }, SoulQuickToggle))
1874
2828
 
2829
+
1875
2830
  // 输入框光轨:注册到输入框卡片内部的 overlay 槽位。
1876
2831
  // 组件不渲染可见内容——它按会话运行态在最近的输入框卡片上挂载/隐藏 SVG 环;
1877
2832
  // 同一份 soulController store 保证与设置页的开关、颜色实时一致。
@@ -1901,8 +2856,8 @@ window.__ModuleLoader__.load({
1901
2856
  })
1902
2857
  }, SoulSettings))
1903
2858
 
1904
- // 加载初始配置
1905
- controller.loadConfig()
2859
+ // 初始配置的拉取已并入上面的全局轮询 —— 它的 refresh() 在注册时立即执行一次
2860
+ // (含 loadConfig + loadPersonas),这里再调一次就是同一次装配发两个相同请求。
1906
2861
  }
1907
2862
 
1908
2863
  exports.apply = apply