dsh-soul 0.7.1 → 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.
package/client/index.mjs CHANGED
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
48
48
  // -------------------------------------------------------------------------
49
49
 
50
50
  const NS = 'soul'
51
- const VERSION = '0.7.1'
51
+ const VERSION = '0.7.2'
52
52
  const INITIAL = {
53
53
  enabled: true,
54
54
  nickname: '',
@@ -367,6 +367,17 @@ window.__ModuleLoader__.load({
367
367
  this.fetcher = fetcher
368
368
  this.store = createSnapshotStore(INITIAL)
369
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
370
381
  }
371
382
 
372
383
  // 把服务端返回的完整配置映射进 store
@@ -463,6 +474,32 @@ window.__ModuleLoader__.load({
463
474
  return await this.postJSON('/api/soul/personas', null, { method: 'GET' })
464
475
  }
465
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
+
466
503
  async savePersona(name) {
467
504
  return await this.postJSON('/api/soul/personas/save', { name })
468
505
  }
@@ -524,107 +561,272 @@ window.__ModuleLoader__.load({
524
561
  // -------------------------------------------------------------------------
525
562
 
526
563
  const css = [
527
- '.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)}',
528
- '.soul-section h3{margin:0 0 16px 0;font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
529
- '.soul-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}',
530
- '.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}',
531
- '.soul-field{margin-bottom:16px;width:100%}',
532
- '.soul-field label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
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)}',
533
578
  // 与上面的 label 同形,但用于「没有对应控件」的分组标题(如光轨示例预览):
534
579
  // 那种位置写 <label> 会是一个指向空的标签,语义错误
535
- '.soul-field-label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
536
- '.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}',
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}',
537
583
  '.soul-field select option{color:var(--dsw-alias-label-primary)}',
538
584
  '.soul-section{color-scheme:light}',
539
585
  'body[data-ds-dark-theme] .soul-section{color-scheme:dark}',
540
586
  '.soul-field select{color-scheme:light}',
541
587
  'body[data-ds-dark-theme] .soul-field select{color-scheme:dark}',
542
- '.soul-field textarea{min-height:100px;resize:vertical}',
543
- '.soul-field textarea.soul-textarea-sm{min-height:60px}',
544
- '.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)}',
545
- '.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)}',
546
598
  '.soul-accordion-open{position:relative;z-index:20}',
547
- // 展开时标题行下沿改直角:与下方内容区形成一条清晰的水平交界,
548
- // 避免"标题与内容同为一片底色、分不清哪块是标题"
549
- '.soul-accordion-open .soul-accordion-trigger{border-radius:5px 5px 0 0}',
550
- '.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}',
551
- '.soul-accordion-trigger:hover{background:var(--dsw-alias-interactive-bg-hover-accent,rgba(127,127,127,0.2))}',
552
- '.soul-accordion-title{flex:0 0 auto;font-size:13px;font-weight:600}',
553
- '.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}',
554
605
  '.soul-accordion-icon{flex:0 0 18px;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:1;text-align:center}',
555
- '.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}',
556
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)}',
557
- '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:16px}',
558
- '.soul-toggle label{margin:0;font-size:13px;color:var(--dsw-alias-label-primary)}',
559
- '.soul-buttons{display:flex;gap:8px;margin-top:16px}',
560
- '.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}',
561
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)}',
562
615
  '.soul-quick-toggle[data-enabled="true"]{color:var(--dsw-alias-brand-primary)}',
563
- '.soul-quick-toggle[data-enabled="true"]:hover{border-color:#ef4444;color:#ef4444;box-shadow:0 0 0 2px rgba(239,68,68,.18)}',
564
- '.soul-quick-toggle[data-enabled="false"]:hover{border-color:#22c55e;color:#22c55e;box-shadow:0 0 0 2px rgba(34,197,94,.18)}',
565
- '.soul-quick-toggle[data-enabled="true"]:hover::before{background:rgba(239,68,68,.24);animation:soul-toggle-red-sweep .55s ease-out both}',
566
- '.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}',
567
620
  '.soul-quick-toggle::before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;transform:translateX(100%)}',
568
621
  '.soul-quick-toggle > *{position:relative;z-index:1}',
569
622
  '.soul-quick-toggle-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}',
570
- '.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}',
571
624
  // 保存失败:小圆点转红并停止脉冲(按钮文案不变,故必须靠颜色 + title/aria-label 反馈)
572
- '.soul-quick-toggle[data-failed="true"]{border-color:var(--dsw-alias-label-error);color:var(--dsw-alias-label-error)}',
573
- '.soul-quick-toggle[data-failed="true"] .soul-quick-toggle-dot{background:var(--dsw-alias-label-error);animation:none}',
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}',
574
629
  '.soul-quick-toggle:disabled{cursor:wait;opacity:.6}',
575
630
  '@keyframes soul-toggle-red-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
576
631
  '@keyframes soul-toggle-green-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
577
- '@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}}',
578
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}}',
579
- '.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}',
580
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}',
581
639
  '.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
582
- '.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))}',
583
641
  '.soul-hint:hover .soul-hint-tip{opacity:1;visibility:visible}',
584
642
  // 键盘聚焦同样要能拿到说明:只有 :hover 的话,键盘 / 读屏用户永远看不到提示
585
643
  '.soul-hint:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;border-radius:3px}',
586
644
  '.soul-hint:focus-visible .soul-hint-tip{opacity:1;visibility:visible}',
587
- '.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
588
- '.soul-dirty{color:#d46b08}',
589
- // 人设预设行:三列网格「名称 | 摘要 | 操作」。
590
- // 用网格而非 flex 是为了让操作列**列宽固定并右对齐**——内容驱动的 flex 下,
591
- // 自建行多一个「删除」按钮会把「使用」挤左约 34px,与内置行的「使用」不在同一竖列。
592
- // 名称列同时容纳「★ 标记」「名称」「来源徽标」,徽标是名称的修饰而非操作,
593
- // 因此放在这里而不是操作区(放操作区会让内置/自建两类的按钮列错位)。
594
- '.soul-persona-row{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px dashed var(--dsw-alias-border-l2);font-size:12px;border-radius:6px}',
595
- // 内置行用底色区分来源:不再靠标签占位,横向空间留给摘要
596
- '.soul-persona-row[data-builtin="true"]{background:var(--dsw-alias-interactive-bg-hover);border-bottom-color:transparent}',
597
- // 名称区:名称 + 徽标。徽标弱化(无边框、更小、更淡),避免与「使用 / 删除」抢注意力
598
- '.soul-persona-name{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-primary);font-weight:500}',
599
- '.soul-persona-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
600
- '.soul-persona-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
601
- // 操作区:两列等宽槽位,「使用」永远在第 1 列、「删除」永远在第 2 列 ⇒ 跨行严格同列。
602
- // 内置行第 2 列为空占位(不留按钮,因为后端也拒绝删除),这正是两类行能对齐的原因。
603
- // 操作区:固定宽度 + 两个等宽轨道,「使用」永远在第 1 轨、删除位永远在第 2 轨。
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 翻转 ——
604
665
  //
605
- // 为什么不能用 `auto` / `minmax()`:每一行的操作区都是**独立的**网格容器,列宽由该行
606
- // 自己的内容算出——内置行第 2 轨为空(宽 0),自建行第 2 轨是「删除」的宽度,于是
607
- // 两行的「使用」仍不同列。只有把容器宽度与轨道都定死,跨行几何才必然一致。
666
+ // 三层职责分离(这是能做真 3D 而不伤叠层几何的关键):
667
+ // .soul-persona-card 叠层位置(translateX/Y/scale,内联)
668
+ // .soul-persona-flipper 翻到哪一面(rotateY 0 ↔ 180)
669
+ // .soul-persona-face-* 两个面各自的内容
670
+ // 若把 rotateY 写进卡片,就要和叠层偏移合成同一个 transform 字符串 ⇒
671
+ // 偏移几何变脆。
608
672
  //
609
- // 9em(12px 字号下 108px)对中英文都足够:中文「使用/删除」约 24px,英文
610
- // 「Use/Delete」约 22/39px,两轨各 54px 都有余量。客户端词典只提供 zh / en,
611
- // 因此这个宽度不需要为其它语言再放宽。
612
- '.soul-persona-actions{display:grid;grid-template-columns:1fr 1fr;justify-items:end;align-items:center;gap:4px 10px;width:9em;white-space:nowrap}',
613
- // 内置行第 2 轨的空占位:只负责让该轨道存在(不是可点控件,所以用 span)
614
- '.soul-persona-slot{display:block;width:0}',
615
- // 内置行第 2 列的空占位:只负责撑住列宽,不是可点控件
616
- '.soul-persona-slot{display:block;width:0}',
617
- '.soul-persona-badge{font-size:10px;line-height:14px;padding:0 5px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
618
- '.soul-persona-save{display:flex;gap:8px}',
619
- '.soul-persona-save input[type=text]{flex:1}',
620
- '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
621
- '.soul-prompt-link:hover{color:var(--dsw-alias-label-primary)}',
622
- '.soul-persona-danger{color:var(--dsw-alias-label-error)}',
623
- '.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}',
624
- '.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}',
625
- '.soul-toast-success{background:#f6ffed;border:1px solid #b7eb8f;color:#52c41a}',
626
- '.soul-toast-error{background:#fff1f0;border:1px solid #ffa39e;color:#f5222d}',
627
- '@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)}}',
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}',
628
830
  // —— 输入框光轨 ——
629
831
  // 几何规则(position/尺寸/contain)同时内联在元素上,见 mountTrailRing;
630
832
  // 这里保留一份等价声明作为兜底,并让样式可读、可被第三方主题安全覆盖(仅颜色/速度/粗细)
@@ -660,19 +862,16 @@ window.__ModuleLoader__.load({
660
862
  '[data-composer-card][data-soul-trail="on"]{--dsw-elevation-stroke-color:transparent}',
661
863
  // 设置页:颜色控件与实时示例
662
864
  '.soul-trail-colors{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
663
- '.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}',
664
866
  '.soul-trail-swatch[data-active="true"]{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
665
- '.soul-trail-picker{width:34px;height:26px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:none;cursor:pointer}',
666
- '.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}',
667
- '.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)}',
668
870
  '.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
669
871
  '.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
670
- // 开了「减少动态效果」时:光轨层停动画;提示条(.soul-toast,内联 fadeInOut)
671
- // 也要停 —— 它此前被漏掉了,于是每次保存/失败都会平移缩放淡出。
672
- // 实测(真实 Chrome + --force-prefers-reduced-motion):内联 animation 属 author
673
- // 普通声明,样式表的普通声明同样能覆盖;这里仍用 !important 以免将来被内联改动
674
- // 或选择器顺序变化影响。
675
- '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}.soul-toast{animation:none!important}}'
872
+ // 开了「减少动态效果」时:光轨层与卡片停动画。
873
+ // 结果提示(绿字/红字)不需要在这里照顾 —— 它们是静态文字、没有动画。
874
+ '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}.soul-persona-card{animation:none!important;transition:none}}'
676
875
  ].join('')
677
876
 
678
877
  const tagId = 'dsh-soul/styles.css'
@@ -710,6 +909,7 @@ window.__ModuleLoader__.load({
710
909
  'quick.enable': '开启个性化',
711
910
  'quick.disable': '关闭个性化',
712
911
  'quick.statusEnabled': '个性化已启用',
912
+ 'quick.custom': '自定义',
713
913
  'quick.statusDisabled': '个性化已关闭',
714
914
  // 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
715
915
  'quick.toggleFailed': '保存失败,请重试',
@@ -738,14 +938,33 @@ window.__ModuleLoader__.load({
738
938
  'field.instructionsPlaceholder': '输入自定义指令,可以是其他行为、回复风格和语调等偏好设置',
739
939
  'group.personas': '人设预设',
740
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': '这个人设没有额外的自定义指令',
741
955
  'personas.savePlaceholder': '预设名称(1-30 字符)',
742
956
  'personas.use': '使用',
957
+ 'personas.active': '使用中',
743
958
  'personas.delete': '删除',
959
+ 'personas.prev': '上一个预设',
960
+ 'personas.next': '下一个预设',
744
961
  'personas.confirmDelete': '确定删除预设「{name}」?',
745
962
  'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
746
963
  // 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
747
964
  'personas.loadFailed': '人设预设加载失败:{reason}',
748
965
  'personas.builtin': '内置',
966
+ 'personas.drift': '当前配置与任何预设都不一致。',
967
+ 'personas.driftAction': '存为新预设',
749
968
  'personas.hint': '预设只保存 Agent 的人设(风格 / 特质 / 回复长度 / 输出语言 / 自定义指令),不含「关于你」;内置预设随插件提供,不可删除',
750
969
  'style.professional': '专业严谨',
751
970
  'style.casual': '轻松自然',
@@ -777,12 +996,25 @@ window.__ModuleLoader__.load({
777
996
  'meta.language.en': '英文',
778
997
  'group.tool': 'Agent 工具',
779
998
  'field.toolConfirm': '人设变更需确认',
780
- 'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
999
+ 'hint.toolConfirm': '开启后,Agent 改人设会先在输入框处弹出审批面板(Enter 允许 / Esc 拒绝)。若会话审批通道不可用(策略为 never),则退化为待确认提议,需用 /soul confirm 处理 —— 此时建议关闭本开关,否则每次改动都要手输命令。',
781
1000
  'button.save': '保存设置',
782
1001
  'button.saving': '保存中...',
1002
+ 'button.cancel': '取消',
783
1003
  'button.reset': '重置默认',
784
1004
  'status.loading': '加载中...',
785
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(打断性提示)
786
1018
  'toast.saved': '✅ 设置已保存',
787
1019
  'toast.noChanges': '✅ 配置无变化',
788
1020
  'toast.reset': '✅ 已重置为默认值',
@@ -838,6 +1070,7 @@ window.__ModuleLoader__.load({
838
1070
  'quick.enable': 'Enable personalization',
839
1071
  'quick.disable': 'Disable personalization',
840
1072
  'quick.statusEnabled': 'Personalization enabled',
1073
+ 'quick.custom': 'Custom',
841
1074
  'quick.statusDisabled': 'Personalization disabled',
842
1075
  // 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
843
1076
  'quick.toggleFailed': 'Save failed, please retry',
@@ -866,14 +1099,31 @@ window.__ModuleLoader__.load({
866
1099
  'field.instructionsPlaceholder': 'Custom instructions — other behaviors, style or tone preferences',
867
1100
  'group.personas': 'Personas',
868
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',
869
1114
  'personas.savePlaceholder': 'Persona name (1-30 chars)',
870
1115
  'personas.use': 'Use',
1116
+ 'personas.active': 'Active',
871
1117
  'personas.delete': 'Delete',
1118
+ 'personas.prev': 'Previous persona',
1119
+ 'personas.next': 'Next persona',
872
1120
  'personas.confirmDelete': 'Delete persona "{name}"?',
873
1121
  'personas.empty': 'No personas yet — save the current config to switch with one click',
874
1122
  // 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
875
1123
  'personas.loadFailed': 'Failed to load personas: {reason}',
876
1124
  'personas.builtin': 'Built-in',
1125
+ 'personas.drift': 'The current config matches no preset.',
1126
+ 'personas.driftAction': 'Save as new preset',
877
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',
878
1128
  'style.professional': 'Professional',
879
1129
  'style.casual': 'Casual',
@@ -905,12 +1155,22 @@ window.__ModuleLoader__.load({
905
1155
  'meta.language.en': 'English',
906
1156
  'group.tool': 'Agent tools',
907
1157
  'field.toolConfirm': 'Require confirmation for persona changes',
908
- '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.',
909
1159
  'button.save': 'Save Settings',
910
1160
  'button.saving': 'Saving...',
1161
+ 'button.cancel': 'Cancel',
911
1162
  'button.reset': 'Reset to Default',
912
1163
  'status.loading': 'Loading...',
913
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}".',
914
1174
  'toast.saved': '✅ Settings saved',
915
1175
  'toast.noChanges': '✅ No changes to save',
916
1176
  'toast.reset': '✅ Reset to defaults',
@@ -1063,6 +1323,8 @@ window.__ModuleLoader__.load({
1063
1323
  const state = useSoulController((state) => state)
1064
1324
  const enabled = state.enabled === true
1065
1325
  const busy = state.saving === true
1326
+ // B:底栏开关上缀当前人设名,与输入框上方的标签互相印证
1327
+ const activePersona = state.activePersona
1066
1328
 
1067
1329
  // 保存失败的本地标记:失败时 store.error 会带上原因,但按钮本身此前毫无反馈
1068
1330
  // (enabled 没变 ⇒ 外观与文案都不变),用户视角是「点了没反应」。
@@ -1090,11 +1352,20 @@ window.__ModuleLoader__.load({
1090
1352
  onClick: toggle
1091
1353
  },
1092
1354
  e('span', { className: 'soul-quick-toggle-dot', 'aria-hidden': true }),
1093
- 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')}`)
1094
1365
  )
1095
1366
  }
1096
1367
 
1097
- function SoulSettings(props) {
1368
+ function SoulSettings(props) {
1098
1369
  const { useSoulController, controller } = props
1099
1370
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
1100
1371
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
@@ -1118,7 +1389,59 @@ window.__ModuleLoader__.load({
1118
1389
  const [localTrailSpeed, setLocalTrailSpeed] = React.useState(trailSpeed || 'slow')
1119
1390
  const [localTrailWidth, setLocalTrailWidth] = React.useState(trailWidth || 'thin')
1120
1391
  const [personaName, setPersonaName] = React.useState('')
1121
- 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。
1122
1445
  const [showPrompt, setShowPrompt] = React.useState(false)
1123
1446
  const [promptText, setPromptText] = React.useState('')
1124
1447
  const [promptLoading, setPromptLoading] = React.useState(false)
@@ -1145,39 +1468,65 @@ window.__ModuleLoader__.load({
1145
1468
  }, [openSection])
1146
1469
 
1147
1470
  // 同步状态
1148
- // 用 store 回填本地表单。
1471
+ // 用 store 回填本地表单 —— 但**按字段**决定,而不是一刀切。
1472
+ //
1473
+ // 此前是「dirty || saving 就整体不回填」:点「使用」应用预设后,store 先变、
1474
+ // 本地表单还是旧值 ⇒ 那一次渲染 dirty 恒为 true ⇒ 回填被挡 ⇒ 表单停在旧值。
1475
+ // 之后「保存设置」亮起(用户没改任何东西也亮),一点保存就把**旧表单值**
1476
+ // 整体 POST 上去,把刚应用的预设覆盖回旧配置 —— 正是「使用后人设变回默认」。
1477
+ //
1478
+ // 现在的判据:记录「上次与 store 同步时的本地快照」。某字段的当前本地值
1479
+ // 仍等于快照 ⇒ 用户没动过它 ⇒ 安全回填新值;不等于 ⇒ 用户正在编辑 ⇒ 保留。
1480
+ // 既有保护(轮询不覆盖正在编辑的内容)逐字段保留,语义不变。
1149
1481
  //
1150
- // **dirty 期间不回填**:否则任何客户端侧对共享 store 的写入(例如快捷开关成功后
1151
- // 的 applyConfig)都会让依赖变化、本 effect 重跑,把用户正在编辑、尚未保存的
1152
- // 内容整片覆盖 —— 且没有任何提示。轮询路径(下方 refresh)已有 `if (dirty ||
1153
- // saving) return` 的保护,这里此前漏了同一道保护。
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
+ }
1154
1495
  React.useEffect(() => {
1155
- if (dirty || saving) return
1156
- setLocalEnabled(enabled)
1157
- setLocalNickname(nickname || '')
1158
- setLocalOccupation(occupation || '')
1159
- setLocalBio(bio || '')
1160
- setLocalStyle(style)
1161
- setLocalHeadingLists(headingLists)
1162
- setLocalEmoji(emoji)
1163
- setLocalTables(tables)
1164
- setLocalReplyLength(replyLength)
1165
- setLocalLanguage(language || 'zh')
1166
- setLocalInstructions(customInstructions)
1167
- setLocalToolConfirm(requireToolConfirmation === true)
1168
- setLocalTrailEnabled(trailEnabled === true)
1169
- setLocalTrailColor(trailColor || TRAIL_COLOR_FALLBACK)
1170
- setLocalTrailSpeed(trailSpeed || 'slow')
1171
- 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,故不会形成死循环。
1172
1525
  }, [enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
1173
1526
 
1174
- // toast 自动消失
1175
- React.useEffect(() => {
1176
- if (toast) {
1177
- const timer = setTimeout(() => setToast(null), 2000)
1178
- return () => clearTimeout(timer)
1179
- }
1180
- }, [toast])
1527
+ // toast 的消失交给宿主 Toast 组件自己(holdMs 后淡出并回调 onDone)。
1528
+ // 此前这里有个 2s 定时器 + 自绘 CSS 动画,两者时长必须手工对齐,
1529
+ // 且与全局提示条的 3s + 1s 淡出不一致 ⇒ 已删除。
1181
1530
 
1182
1531
  // dirty 检测:本地表单与已保存配置逐字段比较。
1183
1532
  //
@@ -1192,6 +1541,13 @@ window.__ModuleLoader__.load({
1192
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 }
1193
1542
  const dirty = FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
1194
1543
 
1544
+ // 宿主 savedNotice 语义:一旦用户开始新的编辑(dirty 变 true),
1545
+ // 上一条「已保存」就过时了 ⇒ 清除。(effect 必须放在 dirty 定义之后:
1546
+ // 依赖数组在渲染期求值,放前面会 TDZ —— 实测踩过。)
1547
+ React.useEffect(() => {
1548
+ if (dirty) setSavedNotice(null)
1549
+ }, [dirty])
1550
+
1195
1551
  // 预览是否反映未保存的编辑:只看参与编译的字段(见 PROMPT_FIELD_KEYS)。
1196
1552
  // 这里用 localMap/savedMap 逐字段比,而不是复用上面的 dirty —— 改光轨颜色会让
1197
1553
  // dirty 为真,但那不影响提示词,此时预览仍是「已生效内容」,标成草稿反而误导。
@@ -1230,49 +1586,28 @@ window.__ModuleLoader__.load({
1230
1586
  // 展开/收起只切状态;编译由下方 effect 统一驱动(唯一入口,避免重复请求)
1231
1587
  const togglePrompt = () => setShowPrompt((open) => !open)
1232
1588
 
1233
- const loadPersonas = async () => {
1234
- try {
1235
- const payload = await controller.fetchPersonas()
1236
- controller.store.update((s) => {
1237
- // 内容相同就保留旧引用:每 2s 轮询都会重新解析出**新对象**,若直接赋值,
1238
- // store 的浅比较永远判为「变了」⇒ 上面那层去重失效,整页仍每 2s 白重渲染。
1239
- s.personas = samePersonas(s.personas, payload.personas) ? s.personas : payload.personas
1240
- s.activePersona = payload.activeName
1241
- // 拉取成功:清掉上一次的失败痕迹(否则失败后即便恢复,提示会一直挂着)
1242
- s.personasError = null
1243
- })
1244
- } catch (err) {
1245
- // 预设列表加载失败不打断主配置界面 —— 但必须**留下痕迹**:
1246
- // 此前是空 catch,personas 永远是 null,摘要与面板就一直显示「加载中...」,
1247
- // 与「真的还没有预设」无从区分,也没有任何失败提示。
1248
- controller.store.update((s) => {
1249
- s.personasError = messageOf(err)
1250
- })
1251
- }
1252
- }
1589
+ // 逻辑已下沉到 controller.loadPersonas(全局轮询与设置页共用一份实现)。
1590
+ const loadPersonas = () => controller.loadPersonas()
1253
1591
 
1254
- // 设置页打开后重新读取宿主配置,补齐 /soul 命令等外部写入路径。
1255
- // 表单 dirty 或正在保存时暂停,避免远端刷新覆盖用户未保存的输入。
1592
+ // 定时轮询已**上移到 apply()**(全局一份,与设置页是否挂载无关)——
1593
+ // 这里只负责告诉它「现在别刷」:有未保存编辑或正在保存时暂停,
1594
+ // 否则远端刷新会把用户正在改的值拽回去。
1595
+ //
1596
+ // 用 effect 而不是渲染期直接赋值:赋值要放在提交后(且卸载时必须复位,
1597
+ // 否则关掉设置页会永久暂停轮询 —— 那正是这次要修的 bug 的镜像)。
1256
1598
  React.useEffect(() => {
1257
- const refresh = () => {
1258
- if (dirty || saving) return
1259
- void controller.loadConfig()
1260
- void loadPersonas()
1261
- }
1262
- const onVisibilityChange = () => {
1263
- if (document.visibilityState === 'visible') refresh()
1264
- }
1265
- refresh()
1266
- window.addEventListener('focus', refresh)
1267
- document.addEventListener('visibilitychange', onVisibilityChange)
1268
- const timer = setInterval(refresh, 2000)
1269
- return () => {
1270
- window.removeEventListener('focus', refresh)
1271
- document.removeEventListener('visibilitychange', onVisibilityChange)
1272
- clearInterval(timer)
1273
- }
1599
+ controller.paused = dirty || saving
1600
+ return () => { controller.paused = false }
1274
1601
  }, [controller, dirty, saving])
1275
1602
 
1603
+ // 进入设置页时立即对齐一次宿主状态(补齐 /soul 命令等外部写入路径),
1604
+ // 不必等下一个 2s 轮询周期。
1605
+ React.useEffect(() => {
1606
+ if (!controller.canRefresh()) return
1607
+ void controller.loadConfig()
1608
+ void controller.loadPersonas()
1609
+ }, [controller])
1610
+
1276
1611
  // 提示词预览的编译入口(唯一)。
1277
1612
  // 展开时立即编译一次;此后只要影响编译的字段或脏状态变化,停止 400ms 再编译 —
1278
1613
  // 边改边看保存后真正会生效的内容。
@@ -1299,13 +1634,16 @@ window.__ModuleLoader__.load({
1299
1634
  const payload = await controller.saveConfig(localMap)
1300
1635
  // saveConfig 失败时内部已记录 error 并返回 undefined:给出失败提示,而非误报「无变化」
1301
1636
  if (!payload) {
1302
- setToast({ text: t('toast.saveFailed'), kind: 'error' })
1637
+ showError(t('toast.saveFailed'))
1303
1638
  return
1304
1639
  }
1305
- setToast({
1306
- text: Array.isArray(payload.changed) && payload.changed.length > 0 ? t('toast.saved') : t('toast.noChanges'),
1307
- kind: 'success'
1308
- })
1640
+ // 成功提示走**内联绿字**(对齐宿主模型设置页的 savedNotice —— 见渲染处注释):
1641
+ // 有实际变化时报「已保存」;无变化时报「配置无变化」。
1642
+ showSavedNotice(
1643
+ Array.isArray(payload.changed) && payload.changed.length > 0
1644
+ ? t('notice.saved')
1645
+ : t('notice.noChanges')
1646
+ )
1309
1647
  // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1310
1648
  // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1311
1649
  }
@@ -1316,27 +1654,27 @@ window.__ModuleLoader__.load({
1316
1654
  // 没成功」这件事藏了起来。
1317
1655
  const payload = await controller.resetConfig()
1318
1656
  if (!payload) {
1319
- setToast({ text: t('toast.resetFailed'), kind: 'error' })
1657
+ showError(t('toast.resetFailed'))
1320
1658
  return
1321
1659
  }
1322
- setToast({
1323
- text: payload.backupPath ? t('toast.resetRecovered') : t('toast.reset'),
1324
- kind: 'success'
1325
- })
1660
+ showSavedNotice(payload.backupPath ? t('notice.resetRecovered') : t('notice.reset'))
1326
1661
  // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1327
1662
  // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1328
1663
  }
1329
1664
 
1665
+ // 存为预设:把「关于你/特质」里的**当前设置**存成一个新预设。
1666
+ // 名字来自命名弹层;成功后关闭弹层并清空输入。
1330
1667
  const handleSavePersona = async () => {
1331
1668
  const name = personaName.trim()
1332
1669
  if (!name) return
1333
1670
  try {
1334
1671
  await controller.savePersona(name)
1335
1672
  setPersonaName('')
1673
+ setShowSavePersona(false)
1336
1674
  await loadPersonas()
1337
- setToast({ text: t('toast.personaSaved'), kind: 'success' })
1675
+ showSavedNotice(t('notice.personaSaved', { name }))
1338
1676
  } catch (err) {
1339
- setToast({ text: messageOf(err), kind: 'error' })
1677
+ showError(messageOf(err))
1340
1678
  }
1341
1679
  }
1342
1680
 
@@ -1346,28 +1684,83 @@ window.__ModuleLoader__.load({
1346
1684
  await loadPersonas()
1347
1685
  // 服务端回报了被丢弃的非法字段 ⇒ 预设只被部分应用,必须说出来而不是报「已应用」
1348
1686
  const invalid = Array.isArray(payload && payload.invalid) ? payload.invalid : []
1349
- const toast = invalid.length > 0
1350
- ? { text: t('toast.personaPartial', { fields: invalid.join(', ') }), kind: 'error' }
1351
- : (payload && payload.unchanged
1352
- ? { text: t('toast.personaUnchanged'), kind: 'success' }
1353
- : { text: t('toast.personaUsed'), kind: 'success' })
1354
- setToast(toast)
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
+ }
1355
1695
  // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1356
1696
  // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1357
1697
  } catch (err) {
1358
- setToast({ text: messageOf(err), kind: 'error' })
1698
+ showError(messageOf(err))
1359
1699
  }
1360
1700
  }
1361
1701
 
1702
+ // 删除预设:**内联二次确认**,不用原生 window.confirm。
1703
+ //
1704
+ // 为什么换掉:原生模态框在 webview 里可能把文档焦点留在异常状态 —— 症状正是用户
1705
+ // 报的「删除后页面看得见却点不动、下拉框与输入框都无法操作」,而最小化再恢复
1706
+ // (触发 focus/visibilitychange)就变好。宿主自己的设置页也不用原生 confirm。
1707
+ //
1708
+ // 交互:第一次点「删除」→ 该按钮变成「确认删除?」且进入待确认态;再点一次才真删。
1709
+ // 期间点别处 / 切换卡片 / 超时(见下方 effect)都会复位,避免误删。
1362
1710
  const handleDeletePersona = async (name) => {
1363
- 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)
1364
1716
  try {
1365
1717
  await controller.deletePersona(name)
1366
1718
  await loadPersonas()
1367
- setToast({ text: t('toast.personaDeleted'), kind: 'success' })
1719
+ showSavedNotice(t('notice.personaDeleted', { name }))
1368
1720
  } catch (err) {
1369
- setToast({ text: messageOf(err), kind: 'error' })
1721
+ showError(messageOf(err))
1722
+ }
1723
+ }
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')
1370
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
1371
1764
  }
1372
1765
 
1373
1766
  const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
@@ -1384,6 +1777,40 @@ window.__ModuleLoader__.load({
1384
1777
  return aBuiltin - bBuiltin
1385
1778
  })
1386
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])
1387
1814
  const aboutFilled = [localNickname, localOccupation, localBio].filter(Boolean).length
1388
1815
  const aboutSummary = aboutFilled > 0 ? `${aboutFilled}/3 ${t('accordion.filled')}` : t('accordion.empty')
1389
1816
  const traitsSummary = t(`style.${localStyle}`)
@@ -1417,6 +1844,20 @@ window.__ModuleLoader__.load({
1417
1844
  return parts.join(' · ')
1418
1845
  }
1419
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
+
1420
1861
  return e('div', { className: 'soul-section' },
1421
1862
  e('div', { className: 'soul-title-row' },
1422
1863
  e('h3', null, t('settings.title')),
@@ -1586,44 +2027,270 @@ window.__ModuleLoader__.load({
1586
2027
  ? e('div', { className: 'soul-status' }, t('status.loading'))
1587
2028
  : (personaNames.length === 0
1588
2029
  ? e('div', { className: 'soul-status' }, t('personas.empty'))
1589
- : e(Fragment, null,
1590
- ...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) => {
1591
2047
  const entry = personas[name] || {}
1592
2048
  const builtin = !!entry.builtin
1593
- // data-builtin 同时承担两件事:内置行的底色区分,以及回归断言的可断言锚点
1594
- return e('div', { className: 'soul-persona-row', key: name, 'data-builtin': builtin ? 'true' : 'false' },
1595
- e('span', { className: 'soul-persona-name' },
1596
- e('span', { className: 'soul-persona-label' }, name === activePersona ? `★ ${name}` : name),
1597
- // 来源徽标紧贴名称:它说明「这个预设从哪来」,属于名称的修饰而非操作,
1598
- // 放在操作区会把「使用」挤出竖列
1599
- builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin'))
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
+ )
1600
2163
  ),
1601
- e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
1602
- e('span', { className: 'soul-persona-actions' },
1603
- e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
1604
- // 内置预设不提供删除(后端也拒绝):留一个空占位槽,让「使用」与
1605
- // 自建行严格同列。这里刻意不是按钮,避免造出一个点了没反应的控件。
1606
- builtin
1607
- ? e('span', { className: 'soul-persona-slot', 'aria-hidden': true })
1608
- : e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
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
+ )
1609
2230
  )
2231
+ ) // ← 关闭 .soul-persona-flipper
1610
2232
  )
1611
- })
1612
- ))
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
+ }, '›')
2273
+ )
2274
+ )
2275
+ })())
1613
2276
  )
1614
2277
  ),
1615
- e('div', { className: 'soul-field soul-persona-save' },
1616
- e('input', {
1617
- type: 'text',
1618
- value: personaName,
1619
- maxLength: 30,
1620
- onChange: (ev) => setPersonaName(ev.target.value),
1621
- placeholder: t('personas.savePlaceholder')
1622
- }),
1623
- e(ui.Button, {
1624
- onClick: handleSavePersona,
1625
- disabled: saving || !personaName.trim()
1626
- }, 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'))
1627
2294
  ),
1628
2295
  e('div', { className: 'soul-status' }, t('personas.hint'))
1629
2296
  ),
@@ -1752,24 +2419,92 @@ window.__ModuleLoader__.load({
1752
2419
  onClick: handleSave,
1753
2420
  disabled: saving || !dirty
1754
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')),
1755
2429
  e(ui.Button, {
1756
2430
  onClick: handleReset,
1757
2431
  disabled: saving
1758
2432
  }, t('button.reset'))
1759
2433
  ),
1760
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
+
1761
2484
  dirty && !saving && e('div', { className: 'soul-status soul-dirty' }, t('status.unsaved')),
1762
2485
 
1763
- toast && e('div', {
1764
- className: `soul-toast ${toast.kind === 'error' ? 'soul-toast-error' : 'soul-toast-success'}`,
1765
- // 读屏播报:toast 2s 后自动移除(见上方计时器),没有 aria-live 时
1766
- // 保存成功 / 失败对读屏用户等于没有发生。
1767
- role: toast.kind === 'error' ? 'alert' : 'status',
1768
- 'aria-live': toast.kind === 'error' ? 'assertive' : 'polite',
1769
- style: {
1770
- animation: 'fadeInOut 2s ease-in-out'
1771
- }
1772
- }, 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)
1773
2508
  )
1774
2509
  }
1775
2510
 
@@ -1886,6 +2621,36 @@ window.__ModuleLoader__.load({
1886
2621
  }, 'dsh-soul: browser lifecycle')
1887
2622
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-soul: dictionaries')
1888
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
+
1889
2654
  // 暗色适配说明:原生 <select> 的弹出选项列表由浏览器按 color-scheme 渲染。
1890
2655
  // 宿主主题呈现器会把 html 的 color-scheme 与 body[data-ds-dark-theme] 随主题
1891
2656
  // 投影到 document(见 dsh-client-ui-layout ThemePresenter),因此这里用纯 CSS
@@ -2061,6 +2826,7 @@ window.__ModuleLoader__.load({
2061
2826
  })
2062
2827
  }, SoulQuickToggle))
2063
2828
 
2829
+
2064
2830
  // 输入框光轨:注册到输入框卡片内部的 overlay 槽位。
2065
2831
  // 组件不渲染可见内容——它按会话运行态在最近的输入框卡片上挂载/隐藏 SVG 环;
2066
2832
  // 同一份 soulController store 保证与设置页的开关、颜色实时一致。
@@ -2090,8 +2856,8 @@ window.__ModuleLoader__.load({
2090
2856
  })
2091
2857
  }, SoulSettings))
2092
2858
 
2093
- // 加载初始配置
2094
- controller.loadConfig()
2859
+ // 初始配置的拉取已并入上面的全局轮询 —— 它的 refresh() 在注册时立即执行一次
2860
+ // (含 loadConfig + loadPersonas),这里再调一次就是同一次装配发两个相同请求。
2095
2861
  }
2096
2862
 
2097
2863
  exports.apply = apply