dsh-soul 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/index.mjs CHANGED
@@ -3,11 +3,12 @@
3
3
  // 在设置页面添加「个性化」栏目:
4
4
  // - 启用/禁用个性化设置
5
5
  // - 「关于你」(昵称/职业/介绍)、回复风格和语调、特质、输出语言、自定义指令
6
- // - 人设预设:保存当前为预设、一键使用(★ 标记当前匹配项)、删除
6
+ // - 人设预设:7 个内置人设(随插件提供、不可删除,行尾标「内置」)+ 保存当前为预设、一键使用(★ 标记当前匹配项)、删除
7
7
  // - Agent 工具:set_persona 确认模式开关
8
8
  // - 输入框光轨:Agent 回复中时输入框边框的流光动效(颜色 / 速度 / 粗细 + 实时示例)
9
9
  // - dirty 检测(无改动禁用保存)、统一 toast 提示
10
- // - 查看当前生效提示词(只读)与字符数
10
+ // - 提示词预览(只读):无未保存编辑时显示「当前生效提示词」,有编辑时改为编译草稿
11
+ // —— 展示保存后真正会生效的内容,并在保存/重置/应用预设后自动刷新
11
12
  //
12
13
  // 光轨另注册到 conversation.input.overlay 槽位(输入框卡片内部),
13
14
  // 运行时按会话 running 状态在卡片上挂载/隐藏 SVG 环,见 SoulTrail。
@@ -47,7 +48,7 @@ window.__ModuleLoader__.load({
47
48
  // -------------------------------------------------------------------------
48
49
 
49
50
  const NS = 'soul'
50
- const VERSION = '0.6.2'
51
+ const VERSION = '0.7.1'
51
52
  const INITIAL = {
52
53
  enabled: true,
53
54
  nickname: '',
@@ -56,6 +57,8 @@ window.__ModuleLoader__.load({
56
57
  style: 'professional',
57
58
  headingLists: 'default',
58
59
  emoji: 'default',
60
+ tables: 'default',
61
+ replyLength: 'normal',
59
62
  language: 'zh',
60
63
  customInstructions: '',
61
64
  // set_persona 确认模式(v0.5.0)
@@ -68,11 +71,10 @@ window.__ModuleLoader__.load({
68
71
  // 人设预设库与当前匹配项(GET /api/soul/personas 带回)
69
72
  personas: null,
70
73
  activePersona: null,
71
- loading: false,
74
+ // 预设列表请求失败的原因(成功时清空)。用于把「加载中」与「加载失败」区分开
75
+ personasError: null,
72
76
  saving: false,
73
- error: null,
74
- // 最近一次保存实际变化的字段名数组(来自写路径的 changed)
75
- lastChanged: null
77
+ error: null
76
78
  }
77
79
 
78
80
  // 第三方插件不能 require('@deepseek-ai/dsh-client-runtime/client')——
@@ -88,13 +90,43 @@ window.__ModuleLoader__.load({
88
90
  update: (mutator) => {
89
91
  const next = { ...state }
90
92
  mutator(next)
91
- state = next
92
- 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
+ }
93
104
  },
94
105
  set: (next) => { state = next; notify() },
95
106
  }
96
107
  }
97
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
+
98
130
  function hostBase() {
99
131
  const origin = globalThis.location?.origin
100
132
  return origin !== void 0 && origin !== 'null' ? origin : 'http://dsh.internal'
@@ -346,6 +378,8 @@ window.__ModuleLoader__.load({
346
378
  s.style = config.style
347
379
  s.headingLists = config.headingLists || 'default'
348
380
  s.emoji = config.emoji || 'default'
381
+ s.tables = config.tables || 'default'
382
+ s.replyLength = config.replyLength || 'normal'
349
383
  s.language = config.language || 'zh'
350
384
  s.customInstructions = config.customInstructions
351
385
  s.requireToolConfirmation = config.requireToolConfirmation === true
@@ -357,18 +391,25 @@ window.__ModuleLoader__.load({
357
391
 
358
392
  async loadConfig() {
359
393
  if (this.disposed) return
360
- this.store.update(s => { s.loading = true; s.error = null })
394
+ // 开始读取就清掉上一次的错误;此前还写了一个 s.loading,但全客户端零处读取
395
+ // (设置页首帧直接显示 INITIAL 默认值),属于只写不读的死状态,已删。
396
+ this.store.update(s => { s.error = null })
361
397
 
362
398
  try {
363
399
  const payload = await this.postJSON('/api/soul/config', null, { method: 'GET' })
364
400
  this.store.update(s => {
365
401
  this.applyConfig(s, payload.config)
366
- s.loading = false
402
+ // 两类问题分开上报、合到同一条提示里:
403
+ // configError —— 磁盘配置读不出来(回退默认值,写入被拒)
404
+ // deliveryWarning —— 配置送不到活动会话(agents 服务不可用等)
405
+ // 两者表面症状都是「改了人设不生效」,所以都要让用户看得见。
406
+ s.error = typeof payload.configError === 'string'
407
+ ? payload.configError
408
+ : (typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null)
367
409
  })
368
410
  } catch (error) {
369
411
  if (this.disposed) return
370
412
  this.store.update(s => {
371
- s.loading = false
372
413
  s.error = messageOf(error)
373
414
  })
374
415
  }
@@ -382,7 +423,8 @@ window.__ModuleLoader__.load({
382
423
  const payload = await this.postJSON('/api/soul/config', config)
383
424
  this.store.update(s => {
384
425
  this.applyConfig(s, payload.config)
385
- s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
426
+ // 保存成功也可能带着送达警告(配置写下了但送不到会话)
427
+ s.error = typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null
386
428
  s.saving = false
387
429
  })
388
430
  return payload
@@ -403,7 +445,7 @@ window.__ModuleLoader__.load({
403
445
  const payload = await this.postJSON('/api/soul/config/reset')
404
446
  this.store.update(s => {
405
447
  this.applyConfig(s, payload.config)
406
- s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
448
+ s.error = typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null
407
449
  s.saving = false
408
450
  })
409
451
  return payload
@@ -430,7 +472,9 @@ window.__ModuleLoader__.load({
430
472
  if (!this.disposed) {
431
473
  this.store.update(s => {
432
474
  this.applyConfig(s, payload.config)
433
- s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
475
+ // 清掉上一轮的失败提示:该端点不返回 deliveryWarning,此前一旦上一轮
476
+ // 保存失败过,切预设成功后红色错误条会一直挂着。
477
+ s.error = null
434
478
  })
435
479
  }
436
480
  return payload
@@ -462,6 +506,14 @@ window.__ModuleLoader__.load({
462
506
  return payload
463
507
  }
464
508
 
509
+ // 预览「未保存的编辑」编译出的提示词:POST 草稿字段,服务端以已保存配置为底
510
+ // 覆盖后编译。不落盘、不影响生效提示词 —— 返回的 invalid 列出未通过校验、因而未计入
511
+ // 预览的字段名,供界面如实提示,避免用户以为改动已生效。
512
+ async previewPrompt(draft) {
513
+ const payload = await this.postJSON('/api/soul/prompt/preview', draft)
514
+ return payload
515
+ }
516
+
465
517
  async dispose() {
466
518
  this.disposed = true
467
519
  }
@@ -478,6 +530,9 @@ window.__ModuleLoader__.load({
478
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}',
479
531
  '.soul-field{margin-bottom:16px;width:100%}',
480
532
  '.soul-field label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
533
+ // 与上面的 label 同形,但用于「没有对应控件」的分组标题(如光轨示例预览):
534
+ // 那种位置写 <label> 会是一个指向空的标签,语义错误
535
+ '.soul-field-label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
481
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}',
482
537
  '.soul-field select option{color:var(--dsw-alias-label-primary)}',
483
538
  '.soul-section{color-scheme:light}',
@@ -513,6 +568,9 @@ window.__ModuleLoader__.load({
513
568
  '.soul-quick-toggle > *{position:relative;z-index:1}',
514
569
  '.soul-quick-toggle-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}',
515
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}',
571
+ // 保存失败:小圆点转红并停止脉冲(按钮文案不变,故必须靠颜色 + 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}',
516
574
  '.soul-quick-toggle:disabled{cursor:wait;opacity:.6}',
517
575
  '@keyframes soul-toggle-red-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
518
576
  '@keyframes soul-toggle-green-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
@@ -523,12 +581,40 @@ window.__ModuleLoader__.load({
523
581
  '.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
524
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)}',
525
583
  '.soul-hint:hover .soul-hint-tip{opacity:1;visibility:visible}',
584
+ // 键盘聚焦同样要能拿到说明:只有 :hover 的话,键盘 / 读屏用户永远看不到提示
585
+ '.soul-hint:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;border-radius:3px}',
586
+ '.soul-hint:focus-visible .soul-hint-tip{opacity:1;visibility:visible}',
526
587
  '.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
527
588
  '.soul-dirty{color:#d46b08}',
528
- '.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}',
529
- '.soul-persona-name{color:var(--dsw-alias-label-primary);font-weight:500;white-space:nowrap}',
530
- '.soul-persona-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
531
- '.soul-persona-actions{display:flex;gap:10px;white-space:nowrap}',
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 轨。
604
+ //
605
+ // 为什么不能用 `auto` / `minmax()`:每一行的操作区都是**独立的**网格容器,列宽由该行
606
+ // 自己的内容算出——内置行第 2 轨为空(宽 0),自建行第 2 轨是「删除」的宽度,于是
607
+ // 两行的「使用」仍不同列。只有把容器宽度与轨道都定死,跨行几何才必然一致。
608
+ //
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}',
532
618
  '.soul-persona-save{display:flex;gap:8px}',
533
619
  '.soul-persona-save input[type=text]{flex:1}',
534
620
  '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
@@ -581,7 +667,12 @@ window.__ModuleLoader__.load({
581
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)}',
582
668
  '.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
583
669
  '.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
584
- '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}}'
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}}'
585
676
  ].join('')
586
677
 
587
678
  const tagId = 'dsh-soul/styles.css'
@@ -610,6 +701,8 @@ window.__ModuleLoader__.load({
610
701
  'settings.title': '个性化设置',
611
702
  'accordion.filled': '项已填写',
612
703
  'accordion.empty': '未填写',
704
+ // 分组摘要在「拉取失败」时的显示(折叠态下用户只看得到这一行)
705
+ 'accordion.loadFailed': '加载失败',
613
706
  'accordion.personas': '个预设',
614
707
  'accordion.on': '已开启',
615
708
  'accordion.off': '未开启',
@@ -618,6 +711,8 @@ window.__ModuleLoader__.load({
618
711
  'quick.disable': '关闭个性化',
619
712
  'quick.statusEnabled': '个性化已启用',
620
713
  'quick.statusDisabled': '个性化已关闭',
714
+ // 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
715
+ 'quick.toggleFailed': '保存失败,请重试',
621
716
  'group.aboutYou': '关于你',
622
717
  'field.nickname': '用户昵称',
623
718
  'field.nicknamePlaceholder': '输入你的昵称,回复时会称呼你',
@@ -632,6 +727,10 @@ window.__ModuleLoader__.load({
632
727
  'hint.headingLists': '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中标题和列表的使用程度。',
633
728
  'field.emoji': '表情符号',
634
729
  'hint.emoji': '在回复风格和语调的基础上选择额外的自定义特质项。控制表情符号的使用程度。',
730
+ 'field.tables': '表格',
731
+ 'hint.tables': '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中表格的使用程度。',
732
+ 'field.replyLength': '回复长度',
733
+ 'hint.replyLength': '控制回答的篇幅。选「适中」时不额外约束,由 Agent 自行判断。',
635
734
  'field.language': '输出语言',
636
735
  'hint.language': '设置 Agent 回复你使用的语言,同时影响 /soul 命令的输出语言。',
637
736
  'field.instructions': '自定义指令',
@@ -644,6 +743,10 @@ window.__ModuleLoader__.load({
644
743
  'personas.delete': '删除',
645
744
  'personas.confirmDelete': '确定删除预设「{name}」?',
646
745
  'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
746
+ // 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
747
+ 'personas.loadFailed': '人设预设加载失败:{reason}',
748
+ 'personas.builtin': '内置',
749
+ 'personas.hint': '预设只保存 Agent 的人设(风格 / 特质 / 回复长度 / 输出语言 / 自定义指令),不含「关于你」;内置预设随插件提供,不可删除',
647
750
  'style.professional': '专业严谨',
648
751
  'style.casual': '轻松自然',
649
752
  'style.humorous': '幽默风趣',
@@ -655,6 +758,23 @@ window.__ModuleLoader__.load({
655
758
  'trait.emoji.default': '默认',
656
759
  'trait.emoji.more': '增强(使用较多表情符号)',
657
760
  'trait.emoji.less': '减弱(尽量减少使用表情符号)',
761
+ 'trait.tables.default': '默认',
762
+ 'trait.tables.more': '增强(优先用表格呈现对比与多字段信息)',
763
+ 'trait.tables.less': '减弱(避免使用表格,改用列表或段落)',
764
+ 'trait.replyLength.concise': '简洁(只讲要点,不展开)',
765
+ 'trait.replyLength.normal': '适中(不额外约束)',
766
+ 'trait.replyLength.detailed': '详尽(充分展开背景与推理)',
767
+ // 预设行摘要用的短标签(trait.* 的完整说明太长,一行放不下)
768
+ 'meta.replyLength.concise': '简洁',
769
+ 'meta.replyLength.detailed': '详尽',
770
+ 'meta.headingLists.more': '多列表',
771
+ 'meta.headingLists.less': '少列表',
772
+ 'meta.emoji.more': '多表情',
773
+ 'meta.emoji.less': '少表情',
774
+ 'meta.tables.more': '多用表格',
775
+ 'meta.tables.less': '少用表格',
776
+ 'meta.language.zh': '中文',
777
+ 'meta.language.en': '英文',
658
778
  'group.tool': 'Agent 工具',
659
779
  'field.toolConfirm': '人设变更需确认',
660
780
  'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
@@ -666,12 +786,14 @@ window.__ModuleLoader__.load({
666
786
  'toast.saved': '✅ 设置已保存',
667
787
  'toast.noChanges': '✅ 配置无变化',
668
788
  'toast.reset': '✅ 已重置为默认值',
789
+ 'toast.resetRecovered': '✅ 已重置为默认值(损坏的配置已备份)',
790
+ 'toast.resetFailed': '❌ 重置失败',
669
791
  'toast.personaSaved': '✅ 预设已保存',
670
792
  'toast.personaUsed': '✅ 预设已应用',
793
+ // 预设含非法取值时服务端只应用合法部分,被跳过的字段必须回报
794
+ 'toast.personaPartial': '⚠ 预设仅部分应用,以下字段取值非法已跳过:{fields}',
671
795
  'toast.personaUnchanged': 'ℹ 预设与当前配置一致',
672
796
  'toast.personaDeleted': '✅ 预设已删除',
673
- 'prompt.view': '查看当前生效提示词',
674
- 'prompt.hide': '收起提示词',
675
797
  'prompt.title': '当前生效提示词',
676
798
  'prompt.summaryEnabled': '已启用',
677
799
  'prompt.summaryDisabled': '已禁用',
@@ -679,6 +801,12 @@ window.__ModuleLoader__.load({
679
801
  'prompt.loading': '提示词加载中...',
680
802
  'prompt.empty': '(个性化已禁用,提示词为空)',
681
803
  'prompt.chars': '当前字符数:{n}',
804
+ 'prompt.titleDraft': '保存后将生效的提示词',
805
+ 'prompt.summaryDraft': '{n} 字符 · 未保存',
806
+ 'prompt.invalid': '以下字段未通过校验,未计入本次预览:{fields}',
807
+ // 列表分隔符:中文用顿号、英文用逗号 + 空格。此前英文界面里也硬编码「、」,
808
+ // 会渲染成 `...excluded from this preview: style、tables`(中英混排)。
809
+ 'common.listSep': '、',
682
810
  'toast.saveFailed': '❌ 保存失败,请检查配置',
683
811
  'trail.title': '输入框光轨',
684
812
  'trail.enable': '启用输入框光轨',
@@ -701,6 +829,8 @@ window.__ModuleLoader__.load({
701
829
  'settings.title': 'Personalization Settings',
702
830
  'accordion.filled': 'fields filled',
703
831
  'accordion.empty': 'No fields set',
832
+ // 分组摘要在「拉取失败」时的显示(折叠态下用户只看得到这一行)
833
+ 'accordion.loadFailed': 'Failed to load',
704
834
  'accordion.personas': 'personas',
705
835
  'accordion.on': 'enabled',
706
836
  'accordion.off': 'disabled',
@@ -709,6 +839,8 @@ window.__ModuleLoader__.load({
709
839
  'quick.disable': 'Disable personalization',
710
840
  'quick.statusEnabled': 'Personalization enabled',
711
841
  'quick.statusDisabled': 'Personalization disabled',
842
+ // 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
843
+ 'quick.toggleFailed': 'Save failed, please retry',
712
844
  'group.aboutYou': 'About you',
713
845
  'field.nickname': 'Nickname',
714
846
  'field.nicknamePlaceholder': 'Your nickname — the agent will address you by it',
@@ -723,6 +855,10 @@ window.__ModuleLoader__.load({
723
855
  'hint.headingLists': 'An extra trait layered on top of style & tone. Controls how much answers rely on headings and lists.',
724
856
  'field.emoji': 'Emoji',
725
857
  'hint.emoji': 'An extra trait layered on top of style & tone. Controls how many emojis answers use.',
858
+ 'field.tables': 'Tables',
859
+ 'hint.tables': 'An extra trait layered on top of style & tone. Controls how much answers rely on tables.',
860
+ 'field.replyLength': 'Reply length',
861
+ 'hint.replyLength': 'Controls how long answers are. "Balanced" adds no constraint and lets the agent decide.',
726
862
  'field.language': 'Output language',
727
863
  'hint.language': 'Sets the language the agent replies in; also affects the /soul command output.',
728
864
  'field.instructions': 'Custom instructions',
@@ -735,6 +871,10 @@ window.__ModuleLoader__.load({
735
871
  'personas.delete': 'Delete',
736
872
  'personas.confirmDelete': 'Delete persona "{name}"?',
737
873
  'personas.empty': 'No personas yet — save the current config to switch with one click',
874
+ // 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
875
+ 'personas.loadFailed': 'Failed to load personas: {reason}',
876
+ 'personas.builtin': 'Built-in',
877
+ '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',
738
878
  'style.professional': 'Professional',
739
879
  'style.casual': 'Casual',
740
880
  'style.humorous': 'Humorous',
@@ -746,6 +886,23 @@ window.__ModuleLoader__.load({
746
886
  'trait.emoji.default': 'Default',
747
887
  'trait.emoji.more': 'More (frequent emoji usage)',
748
888
  'trait.emoji.less': 'Less (minimal emoji usage)',
889
+ 'trait.tables.default': 'Default',
890
+ 'trait.tables.more': 'More (prefer tables for comparisons and multi-field data)',
891
+ 'trait.tables.less': 'Less (avoid tables; use lists or paragraphs)',
892
+ 'trait.replyLength.concise': 'Concise (key points only)',
893
+ 'trait.replyLength.normal': 'Balanced (no extra constraint)',
894
+ 'trait.replyLength.detailed': 'Detailed (expand background and reasoning)',
895
+ // Short labels for the persona row summary (trait.* full text is too long)
896
+ 'meta.replyLength.concise': 'Concise',
897
+ 'meta.replyLength.detailed': 'Detailed',
898
+ 'meta.headingLists.more': 'More lists',
899
+ 'meta.headingLists.less': 'Fewer lists',
900
+ 'meta.emoji.more': 'More emoji',
901
+ 'meta.emoji.less': 'Less emoji',
902
+ 'meta.tables.more': 'More tables',
903
+ 'meta.tables.less': 'Fewer tables',
904
+ 'meta.language.zh': 'Chinese',
905
+ 'meta.language.en': 'English',
749
906
  'group.tool': 'Agent tools',
750
907
  'field.toolConfirm': 'Require confirmation for persona changes',
751
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.',
@@ -757,12 +914,14 @@ window.__ModuleLoader__.load({
757
914
  'toast.saved': '✅ Settings saved',
758
915
  'toast.noChanges': '✅ No changes to save',
759
916
  'toast.reset': '✅ Reset to defaults',
917
+ 'toast.resetRecovered': '✅ Reset to defaults (the damaged config was backed up)',
918
+ 'toast.resetFailed': '❌ Reset failed',
760
919
  'toast.personaSaved': '✅ Persona saved',
761
920
  'toast.personaUsed': '✅ Persona applied',
921
+ // 预设含非法取值时服务端只应用合法部分,被跳过的字段必须回报
922
+ 'toast.personaPartial': '⚠ Persona applied partially; skipped as invalid: {fields}',
762
923
  'toast.personaUnchanged': 'ℹ Persona matches current config',
763
924
  'toast.personaDeleted': '✅ Persona deleted',
764
- 'prompt.view': 'View the active system prompt',
765
- 'prompt.hide': 'Hide the prompt',
766
925
  'prompt.title': 'Active system prompt',
767
926
  'prompt.summaryEnabled': 'Enabled',
768
927
  'prompt.summaryDisabled': 'Disabled',
@@ -770,6 +929,12 @@ window.__ModuleLoader__.load({
770
929
  'prompt.loading': 'Loading prompt...',
771
930
  'prompt.empty': '(personalization disabled — the prompt is empty)',
772
931
  'prompt.chars': 'Current character count: {n}',
932
+ 'prompt.titleDraft': 'Prompt after saving',
933
+ 'prompt.summaryDraft': '{n} chars · unsaved',
934
+ 'prompt.invalid': 'These fields failed validation and are excluded from this preview: {fields}',
935
+ // 列表分隔符:中文用顿号、英文用逗号 + 空格。此前英文界面里也硬编码「、」,
936
+ // 会渲染成 `...excluded from this preview: style、tables`(中英混排)。
937
+ 'common.listSep': ', ',
773
938
  'toast.saveFailed': '❌ Save failed — please check the config',
774
939
  'trail.title': 'Composer light trail',
775
940
  'trail.enable': 'Enable composer light trail',
@@ -811,6 +976,28 @@ window.__ModuleLoader__.load({
811
976
  // 选项取值(label 走词典;取值与 lib/config.mjs 的合法值保持一致)
812
977
  const STYLE_VALUES = ['professional', 'casual', 'humorous', 'roast', 'efficient']
813
978
  const TRAIT_VALUES = ['default', 'more', 'less']
979
+ // 回复长度偏好(与宿主 lib/config.mjs 的 REPLY_LENGTH_VALUES 对齐)
980
+ const REPLY_LENGTH_VALUES = ['concise', 'normal', 'detailed']
981
+ // 输出语言(与宿主 lib/config.mjs 的 LANGUAGE_VALUES 对齐)
982
+ const LANGUAGE_VALUES = ['zh', 'en']
983
+
984
+ // 预设行摘要只显示「与默认值不同」的字段——若把 10 个字段全列出来,每行都是
985
+ // 一长串「默认 / 适中」,反而看不出两个预设到底差在哪。默认值须与宿主
986
+ // lib/config.mjs 的 DEFAULT_CONFIG 保持一致。
987
+ const META_FIELD_VALUES = {
988
+ replyLength: REPLY_LENGTH_VALUES,
989
+ headingLists: TRAIT_VALUES,
990
+ emoji: TRAIT_VALUES,
991
+ tables: TRAIT_VALUES,
992
+ language: LANGUAGE_VALUES
993
+ }
994
+ const META_FIELD_DEFAULTS = {
995
+ replyLength: 'normal',
996
+ headingLists: 'default',
997
+ emoji: 'default',
998
+ tables: 'default',
999
+ language: 'zh'
1000
+ }
814
1001
  // 输出语言选项(两种 UI 语言下均自解释,不进词典)
815
1002
  const LANGUAGE_OPTIONS = [
816
1003
  { value: 'zh', label: '中文' },
@@ -818,11 +1005,19 @@ window.__ModuleLoader__.load({
818
1005
  ]
819
1006
 
820
1007
  // 表单字段与 store 字段的一一对应(dirty 检测与保存载荷共用)
821
- const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
1008
+ const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
822
1009
 
823
- // 提示词小图标:hover 展示说明文字
1010
+ // 其中真正参与 compilePrompt 的字段子集。
1011
+ // 提示词预览只依赖它们:改光轨颜色 / 粗细、切换 set_persona 确认模式都不会改变
1012
+ // 编译结果,不该因此重新请求,也不该让预览被标成「未保存」。
1013
+ const PROMPT_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
1014
+
1015
+ // 提示词小图标:hover / 键盘聚焦都展示说明文字。
1016
+ // 此前只有 :hover 可达,且提示节点用 visibility:hidden —— 那会把它移出无障碍树,
1017
+ // 键盘与读屏用户拿不到字段说明。这里补 tabIndex 让它可以聚焦,并在 :focus-visible
1018
+ // 下显示提示(同时保留 aria-label,聚焦时读屏会念出说明本身)。
824
1019
  function SoulHint(props) {
825
- return e('span', { className: 'soul-hint', 'aria-label': props.text },
1020
+ return e('span', { className: 'soul-hint', 'aria-label': props.text, tabIndex: 0 },
826
1021
  e('svg', {
827
1022
  viewBox: '0 0 24 24',
828
1023
  width: 14,
@@ -869,18 +1064,28 @@ window.__ModuleLoader__.load({
869
1064
  const enabled = state.enabled === true
870
1065
  const busy = state.saving === true
871
1066
 
1067
+ // 保存失败的本地标记:失败时 store.error 会带上原因,但按钮本身此前毫无反馈
1068
+ // (enabled 没变 ⇒ 外观与文案都不变),用户视角是「点了没反应」。
1069
+ const [failed, setFailed] = React.useState(false)
1070
+ const failureText = failed ? (state.error || t('quick.toggleFailed')) : null
1071
+
872
1072
  const toggle = async () => {
873
1073
  if (busy) return
874
- await controller.saveConfig({ enabled: !enabled })
1074
+ setFailed(false)
1075
+ const payload = await controller.saveConfig({ enabled: !enabled })
1076
+ // saveConfig 失败时返回 undefined;成功时返回载荷
1077
+ setFailed(!payload)
875
1078
  }
876
1079
 
877
1080
  return e('button', {
878
1081
  type: 'button',
879
- className: 'soul-quick-toggle',
1082
+ className: failed ? 'soul-quick-toggle soul-quick-toggle-failed' : 'soul-quick-toggle',
880
1083
  'data-enabled': enabled ? 'true' : 'false',
1084
+ 'data-failed': failed ? 'true' : 'false',
881
1085
  'aria-pressed': enabled,
882
- 'aria-label': enabled ? t('quick.disable') : t('quick.enable'),
883
- title: enabled ? t('quick.disable') : t('quick.enable'),
1086
+ // 失败时把原因写进 aria-label / title:否则按钮外观不变,用户无从得知保存失败
1087
+ 'aria-label': failureText || (enabled ? t('quick.disable') : t('quick.enable')),
1088
+ title: failureText || (enabled ? t('quick.disable') : t('quick.enable')),
884
1089
  disabled: busy,
885
1090
  onClick: toggle
886
1091
  },
@@ -894,7 +1099,7 @@ window.__ModuleLoader__.load({
894
1099
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
895
1100
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
896
1101
  const state = useSoulController((state) => state)
897
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, loading, saving, error } = state
1102
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, personasError, saving, error } = state
898
1103
 
899
1104
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
900
1105
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
@@ -903,6 +1108,8 @@ window.__ModuleLoader__.load({
903
1108
  const [localStyle, setLocalStyle] = React.useState(style)
904
1109
  const [localHeadingLists, setLocalHeadingLists] = React.useState(headingLists)
905
1110
  const [localEmoji, setLocalEmoji] = React.useState(emoji)
1111
+ const [localTables, setLocalTables] = React.useState(tables)
1112
+ const [localReplyLength, setLocalReplyLength] = React.useState(replyLength)
906
1113
  const [localLanguage, setLocalLanguage] = React.useState(language || 'zh')
907
1114
  const [localInstructions, setLocalInstructions] = React.useState(customInstructions)
908
1115
  const [localToolConfirm, setLocalToolConfirm] = React.useState(requireToolConfirmation === true)
@@ -916,6 +1123,10 @@ window.__ModuleLoader__.load({
916
1123
  const [promptText, setPromptText] = React.useState('')
917
1124
  const [promptLoading, setPromptLoading] = React.useState(false)
918
1125
  const [promptError, setPromptError] = React.useState(null)
1126
+ // 当前预览的是「未保存的编辑」编译结果(true)还是已生效的提示词(false)
1127
+ const [promptIsDraft, setPromptIsDraft] = React.useState(false)
1128
+ // 未通过校验、因而未计入预览的字段名(服务端返回 invalid)
1129
+ const [promptInvalid, setPromptInvalid] = React.useState([])
919
1130
  const [openSection, setOpenSection] = React.useState(() => {
920
1131
  try {
921
1132
  return globalThis.localStorage?.getItem('dsh-soul:open-section') || 'about'
@@ -934,7 +1145,14 @@ window.__ModuleLoader__.load({
934
1145
  }, [openSection])
935
1146
 
936
1147
  // 同步状态
1148
+ // 用 store 回填本地表单。
1149
+ //
1150
+ // **dirty 期间不回填**:否则任何客户端侧对共享 store 的写入(例如快捷开关成功后
1151
+ // 的 applyConfig)都会让依赖变化、本 effect 重跑,把用户正在编辑、尚未保存的
1152
+ // 内容整片覆盖 —— 且没有任何提示。轮询路径(下方 refresh)已有 `if (dirty ||
1153
+ // saving) return` 的保护,这里此前漏了同一道保护。
937
1154
  React.useEffect(() => {
1155
+ if (dirty || saving) return
938
1156
  setLocalEnabled(enabled)
939
1157
  setLocalNickname(nickname || '')
940
1158
  setLocalOccupation(occupation || '')
@@ -942,6 +1160,8 @@ window.__ModuleLoader__.load({
942
1160
  setLocalStyle(style)
943
1161
  setLocalHeadingLists(headingLists)
944
1162
  setLocalEmoji(emoji)
1163
+ setLocalTables(tables)
1164
+ setLocalReplyLength(replyLength)
945
1165
  setLocalLanguage(language || 'zh')
946
1166
  setLocalInstructions(customInstructions)
947
1167
  setLocalToolConfirm(requireToolConfirmation === true)
@@ -949,7 +1169,7 @@ window.__ModuleLoader__.load({
949
1169
  setLocalTrailColor(trailColor || TRAIL_COLOR_FALLBACK)
950
1170
  setLocalTrailSpeed(trailSpeed || 'slow')
951
1171
  setLocalTrailWidth(trailWidth || 'thin')
952
- }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
1172
+ }, [enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
953
1173
 
954
1174
  // toast 自动消失
955
1175
  React.useEffect(() => {
@@ -959,17 +1179,47 @@ window.__ModuleLoader__.load({
959
1179
  }
960
1180
  }, [toast])
961
1181
 
962
- // dirty 检测:本地表单与已保存配置逐字段比较
963
- const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
964
- const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm, trailEnabled: localTrailEnabled, trailColor: localTrailColor, trailSpeed: localTrailSpeed, trailWidth: localTrailWidth }
965
- const dirty = FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
1182
+ // dirty 检测:本地表单与已保存配置逐字段比较。
1183
+ //
1184
+ // 字符串一律先 trim 再比:服务端对每个字符串字段都会 trim(文本字段 trim、枚举值
1185
+ // trim、颜色 trim),所以「只改了首尾空格」落盘后与原值完全相同。此前按原始串
1186
+ // 比较会出现「用户敲了个空格 → 保存按钮亮了 → 保存成功却提示『配置无变化』」。
1187
+ // 不另造一份 TEXT_FIELDS 复刻表:那会多一处需要手工同步的常量。
1188
+ const sameValue = (a, b) => (typeof a === 'string' && typeof b === 'string'
1189
+ ? a.trim() === b.trim()
1190
+ : a === b)
1191
+ const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
1192
+ 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
+ const dirty = FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
1194
+
1195
+ // 预览是否反映未保存的编辑:只看参与编译的字段(见 PROMPT_FIELD_KEYS)。
1196
+ // 这里用 localMap/savedMap 逐字段比,而不是复用上面的 dirty —— 改光轨颜色会让
1197
+ // dirty 为真,但那不影响提示词,此时预览仍是「已生效内容」,标成草稿反而误导。
1198
+ // 必须与 dirty 用**同一个** sameValue 判据:否则「只改了首尾空格」时 dirty 为假
1199
+ // (保存按钮禁用)而 promptDirty 为真(预览标成草稿),两处自相矛盾。
1200
+ const promptDirty = PROMPT_FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
1201
+ // 草稿指纹:仅在影响编译的字段真的变化时才重新预览
1202
+ const promptDraftKey = PROMPT_FIELD_KEYS.map((key) => String(localMap[key])).join('\u0000')
1203
+
1204
+ // 只把参与编译的字段发给预览端点:光轨等字段的校验失败不该出现在提示词预览里
1205
+ const promptDraft = () => {
1206
+ const out = {}
1207
+ for (const key of PROMPT_FIELD_KEYS) out[key] = localMap[key]
1208
+ return out
1209
+ }
966
1210
 
1211
+ // 有未保存编辑时预览草稿(=保存后真正会生效的内容),否则预览已生效提示词
967
1212
  const loadPrompt = async () => {
1213
+ const asDraft = promptDirty
968
1214
  setPromptLoading(true)
969
1215
  setPromptError(null)
970
1216
  try {
971
- const payload = await controller.fetchPrompt()
1217
+ const payload = asDraft
1218
+ ? await controller.previewPrompt(promptDraft())
1219
+ : await controller.fetchPrompt()
972
1220
  setPromptText(payload.prompt || '')
1221
+ setPromptInvalid(Array.isArray(payload.invalid) ? payload.invalid : [])
1222
+ setPromptIsDraft(asDraft)
973
1223
  } catch (err) {
974
1224
  setPromptError(messageOf(err))
975
1225
  } finally {
@@ -977,21 +1227,27 @@ window.__ModuleLoader__.load({
977
1227
  }
978
1228
  }
979
1229
 
980
- const togglePrompt = async () => {
981
- const next = !showPrompt
982
- setShowPrompt(next)
983
- if (next) await loadPrompt()
984
- }
1230
+ // 展开/收起只切状态;编译由下方 effect 统一驱动(唯一入口,避免重复请求)
1231
+ const togglePrompt = () => setShowPrompt((open) => !open)
985
1232
 
986
1233
  const loadPersonas = async () => {
987
1234
  try {
988
1235
  const payload = await controller.fetchPersonas()
989
1236
  controller.store.update((s) => {
990
- s.personas = payload.personas
1237
+ // 内容相同就保留旧引用:每 2s 轮询都会重新解析出**新对象**,若直接赋值,
1238
+ // store 的浅比较永远判为「变了」⇒ 上面那层去重失效,整页仍每 2s 白重渲染。
1239
+ s.personas = samePersonas(s.personas, payload.personas) ? s.personas : payload.personas
991
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)
992
1250
  })
993
- } catch {
994
- // 预设列表加载失败不打断主配置界面
995
1251
  }
996
1252
  }
997
1253
 
@@ -1017,6 +1273,28 @@ window.__ModuleLoader__.load({
1017
1273
  }
1018
1274
  }, [controller, dirty, saving])
1019
1275
 
1276
+ // 提示词预览的编译入口(唯一)。
1277
+ // 展开时立即编译一次;此后只要影响编译的字段或脏状态变化,停止 400ms 再编译 —
1278
+ // 边改边看保存后真正会生效的内容。
1279
+ // promptSettled 用来区分「刚展开」与「展开后字段又变了」:否则展开这一动作自身
1280
+ // 会先立即编译一次、再被防抖重复编译一次。
1281
+ // 依赖里刻意不放 loadPrompt(组件体内每次渲染都会重建函数,放进去会无限循环);
1282
+ // 两个依赖项任一变化都会让 effect 带着最新闭包重跑,闭包不会过期。
1283
+ const promptSettled = React.useRef(false)
1284
+ React.useEffect(() => {
1285
+ if (!showPrompt) {
1286
+ promptSettled.current = false
1287
+ return
1288
+ }
1289
+ if (!promptSettled.current) {
1290
+ promptSettled.current = true
1291
+ void loadPrompt()
1292
+ return
1293
+ }
1294
+ const timer = setTimeout(() => { void loadPrompt() }, 400)
1295
+ return () => clearTimeout(timer)
1296
+ }, [showPrompt, promptDraftKey, promptDirty])
1297
+
1020
1298
  const handleSave = async () => {
1021
1299
  const payload = await controller.saveConfig(localMap)
1022
1300
  // saveConfig 失败时内部已记录 error 并返回 undefined:给出失败提示,而非误报「无变化」
@@ -1028,13 +1306,25 @@ window.__ModuleLoader__.load({
1028
1306
  text: Array.isArray(payload.changed) && payload.changed.length > 0 ? t('toast.saved') : t('toast.noChanges'),
1029
1307
  kind: 'success'
1030
1308
  })
1031
- if (showPrompt) await loadPrompt()
1309
+ // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1310
+ // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1032
1311
  }
1033
1312
 
1034
1313
  const handleReset = async () => {
1035
- await controller.resetConfig()
1036
- setToast({ text: t('toast.reset'), kind: 'success' })
1037
- if (showPrompt) await loadPrompt()
1314
+ // resetConfig 失败时内部已记录 error 并返回 undefined。
1315
+ // 此前这里无条件报「已重置」——失败也显示成功,正好把「配置损坏时唯一的自救操作
1316
+ // 没成功」这件事藏了起来。
1317
+ const payload = await controller.resetConfig()
1318
+ if (!payload) {
1319
+ setToast({ text: t('toast.resetFailed'), kind: 'error' })
1320
+ return
1321
+ }
1322
+ setToast({
1323
+ text: payload.backupPath ? t('toast.resetRecovered') : t('toast.reset'),
1324
+ kind: 'success'
1325
+ })
1326
+ // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1327
+ // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1038
1328
  }
1039
1329
 
1040
1330
  const handleSavePersona = async () => {
@@ -1054,8 +1344,16 @@ window.__ModuleLoader__.load({
1054
1344
  try {
1055
1345
  const payload = await controller.usePersona(name)
1056
1346
  await loadPersonas()
1057
- setToast({ text: payload && payload.unchanged ? t('toast.personaUnchanged') : t('toast.personaUsed'), kind: 'success' })
1058
- if (showPrompt) await loadPrompt()
1347
+ // 服务端回报了被丢弃的非法字段 ⇒ 预设只被部分应用,必须说出来而不是报「已应用」
1348
+ 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)
1355
+ // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1356
+ // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1059
1357
  } catch (err) {
1060
1358
  setToast({ text: messageOf(err), kind: 'error' })
1061
1359
  }
@@ -1075,24 +1373,47 @@ window.__ModuleLoader__.load({
1075
1373
  const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
1076
1374
  const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
1077
1375
  const emojiOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.emoji.${value}`) }))
1078
- const personaNames = personas ? Object.keys(personas).sort() : null
1376
+ const tablesOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.tables.${value}`) }))
1377
+ const replyLengthOptions = REPLY_LENGTH_VALUES.map((value) => ({ value, label: t(`trait.replyLength.${value}`) }))
1378
+ // 内置预设排在前面(沿用宿主返回的定义顺序,便于发现),用户预设紧随其后。
1379
+ // Array.prototype.sort 自 ES2019 起保证稳定,同类项因此维持原有插入顺序。
1380
+ const personaNames = personas
1381
+ ? Object.keys(personas).sort((a, b) => {
1382
+ const aBuiltin = personas[a] && personas[a].builtin ? 0 : 1
1383
+ const bBuiltin = personas[b] && personas[b].builtin ? 0 : 1
1384
+ return aBuiltin - bBuiltin
1385
+ })
1386
+ : null
1079
1387
  const aboutFilled = [localNickname, localOccupation, localBio].filter(Boolean).length
1080
1388
  const aboutSummary = aboutFilled > 0 ? `${aboutFilled}/3 ${t('accordion.filled')}` : t('accordion.empty')
1081
1389
  const traitsSummary = t(`style.${localStyle}`)
1082
- const personasSummary = personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`
1390
+ // 失败时摘要也要区分(折叠状态下用户只看得到这一行)
1391
+ const personasSummary = personasError
1392
+ ? t('accordion.loadFailed')
1393
+ : (personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`)
1083
1394
  const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
1084
1395
  const promptSummary = promptText.length > 0
1085
- ? t('prompt.summaryChars', { n: String(promptText.length) })
1396
+ ? t(promptIsDraft ? 'prompt.summaryDraft' : 'prompt.summaryChars', { n: String(promptText.length) })
1086
1397
  : (enabled ? t('prompt.summaryEnabled') : t('prompt.summaryDisabled'))
1087
1398
  const toggleSection = (section) => () => {
1088
1399
  setOpenSection(current => current === section ? null : section)
1089
1400
  }
1090
1401
 
1402
+ // 预设行摘要:风格 + 所有「偏离默认值」的人设维度。
1403
+ // 预设只描述 Agent 的人格,不含「关于你」(昵称 / 职业 / 介绍)—— 那是使用者
1404
+ // 的身份信息,不随预设切换。所以摘要里不再出现昵称,剩下的恰好也是比较两个
1405
+ // 预设时最需要看到的信息。
1091
1406
  const personaRowMeta = (entry) => {
1407
+ const row = entry || {}
1092
1408
  const parts = []
1093
- if (entry && STYLE_VALUES.includes(entry.style)) parts.push(t(`style.${entry.style}`))
1094
- else if (entry && entry.style) parts.push(entry.style)
1095
- if (entry && entry.nickname) parts.push(entry.nickname)
1409
+ if (STYLE_VALUES.includes(row.style)) parts.push(t(`style.${row.style}`))
1410
+ else if (row.style) parts.push(row.style)
1411
+ for (const [field, allowed] of Object.entries(META_FIELD_VALUES)) {
1412
+ const value = row[field]
1413
+ if (!value || value === META_FIELD_DEFAULTS[field]) continue
1414
+ if (!allowed.includes(value)) continue
1415
+ parts.push(t(`meta.${field}.${value}`))
1416
+ }
1096
1417
  return parts.join(' · ')
1097
1418
  }
1098
1419
 
@@ -1197,6 +1518,32 @@ window.__ModuleLoader__.load({
1197
1518
  ...emojiOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1198
1519
  )
1199
1520
  ),
1521
+ e('div', { className: 'soul-field' },
1522
+ e('label', { htmlFor: 'soul-tables' },
1523
+ t('field.tables'),
1524
+ e(SoulHint, { text: t('hint.tables') })
1525
+ ),
1526
+ e('select', {
1527
+ id: 'soul-tables',
1528
+ value: localTables,
1529
+ onChange: (ev) => setLocalTables(ev.target.value)
1530
+ },
1531
+ ...tablesOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1532
+ )
1533
+ ),
1534
+ e('div', { className: 'soul-field' },
1535
+ e('label', { htmlFor: 'soul-replyLength' },
1536
+ t('field.replyLength'),
1537
+ e(SoulHint, { text: t('hint.replyLength') })
1538
+ ),
1539
+ e('select', {
1540
+ id: 'soul-replyLength',
1541
+ value: localReplyLength,
1542
+ onChange: (ev) => setLocalReplyLength(ev.target.value)
1543
+ },
1544
+ ...replyLengthOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1545
+ )
1546
+ ),
1200
1547
  e('div', { className: 'soul-field' },
1201
1548
  e('label', { htmlFor: 'soul-language' },
1202
1549
  t('field.language'),
@@ -1232,23 +1579,38 @@ window.__ModuleLoader__.load({
1232
1579
  onToggle: toggleSection('personas')
1233
1580
  },
1234
1581
  e('div', { className: 'soul-field' },
1235
- personaNames === null
1582
+ // 加载失败优先于「加载中」:否则失败时界面永远停在加载态,与「还没有预设」无从区分
1583
+ personasError
1584
+ ? e('div', { className: 'soul-error' }, t('personas.loadFailed', { reason: personasError }))
1585
+ : (personaNames === null
1236
1586
  ? e('div', { className: 'soul-status' }, t('status.loading'))
1237
1587
  : (personaNames.length === 0
1238
1588
  ? e('div', { className: 'soul-status' }, t('personas.empty'))
1239
1589
  : e(Fragment, null,
1240
1590
  ...personaNames.map((name) => {
1241
1591
  const entry = personas[name] || {}
1242
- return e('div', { className: 'soul-persona-row', key: name },
1243
- e('span', { className: 'soul-persona-name' }, name === activePersona ? `★ ${name}` : name),
1592
+ 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'))
1600
+ ),
1244
1601
  e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
1245
1602
  e('span', { className: 'soul-persona-actions' },
1246
1603
  e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
1247
- e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
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'))
1248
1609
  )
1249
1610
  )
1250
1611
  })
1251
1612
  ))
1613
+ )
1252
1614
  ),
1253
1615
  e('div', { className: 'soul-field soul-persona-save' },
1254
1616
  e('input', {
@@ -1262,7 +1624,8 @@ window.__ModuleLoader__.load({
1262
1624
  onClick: handleSavePersona,
1263
1625
  disabled: saving || !personaName.trim()
1264
1626
  }, t('personas.save'))
1265
- )
1627
+ ),
1628
+ e('div', { className: 'soul-status' }, t('personas.hint'))
1266
1629
  ),
1267
1630
 
1268
1631
  e(SoulAccordion, {
@@ -1348,9 +1711,10 @@ window.__ModuleLoader__.load({
1348
1711
  )
1349
1712
  ),
1350
1713
  e('div', { className: 'soul-field' },
1351
- e('label', null, t('trail.preview')),
1714
+ // 预览示例不是表单控件,没有可关联的 id —— 用 label 会是「指向空的标签」,
1715
+ // 所以改用同风格的 div(样式见 .soul-field-label)
1716
+ e('div', { className: 'soul-field-label' }, t('trail.preview')),
1352
1717
  e(SoulTrailPreview, {
1353
- enabled: localTrailEnabled,
1354
1718
  color: localTrailColor,
1355
1719
  speed: localTrailSpeed,
1356
1720
  width: localTrailWidth,
@@ -1363,7 +1727,7 @@ window.__ModuleLoader__.load({
1363
1727
 
1364
1728
  e(SoulAccordion, {
1365
1729
  id: 'soul-prompt',
1366
- title: t('prompt.title'),
1730
+ title: t(promptIsDraft ? 'prompt.titleDraft' : 'prompt.title'),
1367
1731
  summary: promptSummary,
1368
1732
  open: showPrompt,
1369
1733
  onToggle: togglePrompt
@@ -1374,7 +1738,9 @@ window.__ModuleLoader__.load({
1374
1738
  ? e('div', { className: 'soul-error' }, promptError)
1375
1739
  : e(Fragment, null,
1376
1740
  e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
1377
- e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) }))
1741
+ e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) })),
1742
+ promptInvalid.length > 0 && e('div', { className: 'soul-error' },
1743
+ t('prompt.invalid', { fields: promptInvalid.join(t('common.listSep')) }))
1378
1744
  ))
1379
1745
  ),
1380
1746
 
@@ -1396,6 +1762,10 @@ window.__ModuleLoader__.load({
1396
1762
 
1397
1763
  toast && e('div', {
1398
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',
1399
1769
  style: {
1400
1770
  animation: 'fadeInOut 2s ease-in-out'
1401
1771
  }
@@ -1409,33 +1779,29 @@ window.__ModuleLoader__.load({
1409
1779
 
1410
1780
  // 设置页内的实时示例:与线上光轨共用同一渲染层、同一挂载点解析(resolveTrailMountPoint)
1411
1781
  // 与同一份 CSS,因此「示例所见」即「回复时所得」;颜色/速度/粗细随表单实时联动。
1782
+ //
1783
+ // 本组件**只在光轨开启时**被渲染(见调用处的 `localTrailEnabled &&`),所以它没有
1784
+ // 「关闭」这一态:此前它接了一个 `enabled` prop 并写了 off 分支,那是永不执行的死
1785
+ // 代码(且 `data-soul-trail="off"` 会连带影响 CSS 选择器),现在去掉。
1412
1786
  function SoulTrailPreview(props) {
1413
1787
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
1414
1788
  const hostRef = React.useRef(null)
1415
1789
  const anchorRef = React.useRef(null)
1416
- const mountedRef = React.useRef(null)
1417
1790
 
1418
1791
  React.useEffect(() => {
1419
1792
  const host = hostRef.current
1420
1793
  if (!host) return undefined
1421
1794
  // 与线上 SoulTrail 同一条路径:解析出的挂载点即示例内的零高度锚点 .soul-trail-anchor
1422
1795
  const mounted = mountTrailRing(host, resolveTrailMountPoint(host, anchorRef.current))
1423
- mountedRef.current = mounted
1424
- return () => {
1425
- mountedRef.current = null
1426
- mounted.unmount()
1427
- }
1796
+ // 环的内联样式以 display:none 起(见 mountTrailRing),必须显式显形一次
1797
+ mounted.setVisible(true)
1798
+ return () => mounted.unmount()
1428
1799
  }, [])
1429
1800
 
1430
- React.useEffect(() => {
1431
- const mounted = mountedRef.current
1432
- if (mounted) mounted.setVisible(props.enabled !== false)
1433
- }, [props.enabled])
1434
-
1435
1801
  return e('div', {
1436
1802
  ref: hostRef,
1437
1803
  className: 'soul-trail-preview',
1438
- 'data-soul-trail': props.enabled ? 'on' : 'off',
1804
+ 'data-soul-trail': 'on',
1439
1805
  'data-soul-trail-speed': props.speed || 'slow',
1440
1806
  'data-soul-trail-width': props.width || 'thin',
1441
1807
  style: { '--soul-trail-color': safeTrailColor(props.color) }
@@ -1528,13 +1894,84 @@ window.__ModuleLoader__.load({
1528
1894
  // 导航 label / 图标替换用的翻译函数(bind 返回的函数在调用时读取当前语言)
1529
1895
  const navT = ctx.locale.bind(NS)
1530
1896
 
1531
- // DSH 0.1.x 不支持 settings.section 的 icon 字段
1532
- // 使用 DOM 操作动态替换图标(类似 dsh-better-sidebar 的方案)
1897
+ // DSH 的设置导航图标是壳层硬编码的 id → 图标映射(见 ui-settings-general 的
1898
+ // navIcon():account/models/agent-presets/plugins/archived-sessions,其余一律
1899
+ // 回退成设置齿轮),插件无法通过槽位声明自己的图标(settings.section 的选项
1900
+ // 只有 id / order / label)。因此改用 DOM 替换,把齿轮换成与插件图标同一套
1901
+ // 几何的「灵魂火花 + 光轨」(类似 dsh-better-sidebar 的方案)。
1533
1902
  const SOUL_NAV_MARKER = 'data-soul-settings-nav'
1534
1903
  const SOUL_ICON_MARKER = 'data-soul-icon-replaced'
1535
1904
 
1905
+ // 与 assets/icon.svg(插件图标)同源几何:同一条光轨与火花路径。
1906
+ // 客户端读不到文件系统,只能内联一份;verify-nav-icon.mjs 有断言核对二者的
1907
+ // 路径数据逐字一致,防止漂移。
1908
+ //
1909
+ // 与插件图标有两处**有意差异**,都是为了与设置导航里的兄弟栏目看齐:
1910
+ // 1) 颜色——插件图标是彩色渐变,而导航栏那一排图标全是单色线描(壳层
1911
+ // navIcon() 生成的宿主机图标用 stroke="currentColor")。这里同样用
1912
+ // currentColor:既与邻居统一,又能自动跟随主题、选中态与禁用态,
1913
+ // 不必自己判断暗色模式。
1914
+ // 2) 显示窗口——icon.svg 用 `0 0 36 36`(图形含描边占 26.8 单位,留白适合
1915
+ // 列表里的大图标)。导航栏旁的宿主图标实测几乎满格——16px 下墨迹跨度
1916
+ // 14.25px(占 89%),所以沿用 36 网格会让这个小图标显得又小又轻。这里把
1917
+ // 窗口收紧到「同一个中心 (18,18)、边长 28.4」,让墨迹跨度落到 ~14px。
1918
+ // 数值是量出来的:预览探针把两种图标渲染到 canvas 逐像素统计。
1919
+ const NAV_ICON_VIEW_BOX = '3.8 3.8 28.4 28.4'
1920
+ const NAV_ICON_TRAIL_D = 'M18 6A12 12 0 1 1 6.72 22.1'
1921
+ const NAV_ICON_SPARK_D = 'M18 11.6C18.52 15.15 20.85 17.48 24.4 18C20.85 18.52 18.52 20.85 18 24.4C17.48 20.85 15.15 18.52 11.6 18C15.15 17.48 17.48 15.15 18 11.6Z'
1922
+ // 线宽按**显示后的像素**与邻居对齐:宿主图标是 16 网格 + 1.3 线宽,而我们
1923
+ // 的窗口是 28.4 单位宽、显示成 16px(缩放 16 ÷ 28.4 ≈ 0.5634),所以取
1924
+ // 1.3 ÷ 0.5634 ≈ 2.3。照搬 icon.svg 的 2.8 会让弧线比邻居粗一圈。
1925
+ const NAV_ICON_STROKE_WIDTH = '2.3'
1926
+ // 宿主按 size=16 渲染,宽高是 svg 的属性(不是 CSS)。万一将来改由 CSS 控制尺寸,
1927
+ // 我们也补一个 16——否则 svg 会退回 300×150 的默认尺寸,把导航栏撑坏。
1928
+ const NAV_ICON_FALLBACK_SIZE = '16'
1929
+ const SVG_NS = 'http://www.w3.org/2000/svg'
1930
+
1931
+ /** 建一个 SVG 元素并铺上属性(避免 innerHTML,无需解析)。 */
1932
+ const svgNode = (name, attributes) => {
1933
+ const node = document.createElementNS(SVG_NS, name)
1934
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value)
1935
+ return node
1936
+ }
1937
+
1938
+ /**
1939
+ * 把导航按钮里宿主生成的齿轮图标改画成插件图标(单色版)。
1940
+ *
1941
+ * 只替换 <svg> 的内容与 viewBox,保留节点本身——宿主的 className
1942
+ * (…navIcon,flex:none)、width/height(size=16)与 aria-hidden 因此
1943
+ * 原样生效,不必自己复刻一套尺寸规则;只有宿主将来不给宽高时才补默认值。
1944
+ *
1945
+ * 宿主 svg 上挂着 fill="none" 与 stroke-width(medium 为 1.3),这两条会
1946
+ * 向下继承,所以每条 path 都写全自身样式:光轨只要描边,火花只要填充。
1947
+ * 颜色一律 currentColor,取到的是按钮文字色——与旁边几个图标同一个值。
1948
+ */
1949
+ const paintSoulNavIcon = (svg) => {
1950
+ svg.setAttribute('viewBox', NAV_ICON_VIEW_BOX)
1951
+ if (!svg.getAttribute('width')) svg.setAttribute('width', NAV_ICON_FALLBACK_SIZE)
1952
+ if (!svg.getAttribute('height')) svg.setAttribute('height', NAV_ICON_FALLBACK_SIZE)
1953
+
1954
+ const trail = svgNode('path', {
1955
+ d: NAV_ICON_TRAIL_D,
1956
+ fill: 'none',
1957
+ stroke: 'currentColor',
1958
+ 'stroke-width': NAV_ICON_STROKE_WIDTH,
1959
+ 'stroke-linecap': 'round'
1960
+ })
1961
+ const spark = svgNode('path', {
1962
+ d: NAV_ICON_SPARK_D,
1963
+ fill: 'currentColor',
1964
+ stroke: 'none'
1965
+ })
1966
+
1967
+ svg.replaceChildren(trail, spark)
1968
+ }
1969
+
1536
1970
  const registerSettingsNavIcon = () => {
1537
1971
  let disposed = false
1972
+ // 改画前记下宿主 svg 的原始子节点:此前卸载时只清 marker 属性、不还原内容,
1973
+ // 于是禁用插件 / HMR 之后导航栏仍显示插件图标,直到壳层重建那个按钮。
1974
+ const originalIcons = new Map()
1538
1975
 
1539
1976
  const sync = () => {
1540
1977
  if (disposed) return
@@ -1548,28 +1985,13 @@ window.__ModuleLoader__.load({
1548
1985
  // 检查是否已经替换过图标
1549
1986
  if (button.hasAttribute(SOUL_ICON_MARKER)) continue
1550
1987
 
1551
- // 查找并替换图标
1988
+ // 把宿主给未知 id 用的设置齿轮,改画成插件自身的图标
1552
1989
  const existingIcon = button.querySelector('svg')
1553
1990
  if (existingIcon) {
1554
- // 标记已替换
1991
+ // 标记已替换;React 重建按钮时标记随之消失,会重新画一次
1555
1992
  button.setAttribute(SOUL_ICON_MARKER, 'true')
1556
-
1557
- // 创建星形图标(跟随主题色)
1558
- const starSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1559
- starSvg.setAttribute('viewBox', '0 0 24 24')
1560
- starSvg.setAttribute('fill', 'none')
1561
- starSvg.setAttribute('stroke', 'currentColor')
1562
- starSvg.setAttribute('stroke-width', '2')
1563
- starSvg.setAttribute('stroke-linecap', 'round')
1564
- starSvg.setAttribute('stroke-linejoin', 'round')
1565
- starSvg.style.width = '16px'
1566
- starSvg.style.height = '16px'
1567
-
1568
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1569
- path.setAttribute('d', 'M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z')
1570
- starSvg.appendChild(path)
1571
-
1572
- existingIcon.replaceWith(starSvg)
1993
+ originalIcons.set(existingIcon, Array.from(existingIcon.childNodes))
1994
+ paintSoulNavIcon(existingIcon)
1573
1995
  }
1574
1996
  } else {
1575
1997
  button.removeAttribute(SOUL_NAV_MARKER)
@@ -1579,14 +2001,24 @@ window.__ModuleLoader__.load({
1579
2001
 
1580
2002
  // 延迟执行初始同步,等待设置页面渲染完成
1581
2003
  let timer = setTimeout(sync, 500)
2004
+ // 最长等待:防抖只该合并「一连串变化」,不该被无限推迟 —— 会话流式输出时
2005
+ // body 每帧都在变,纯 100ms 防抖会让 sync 永远排不到(图标一直不替换)。
2006
+ const MAX_WAIT_MS = 600
2007
+ let pendingSince = 0
1582
2008
 
1583
2009
  // 监听 DOM 变化(只监听子节点添加,不监听所有变化)
1584
2010
  const observer = new MutationObserver(() => {
1585
2011
  // 防抖:每次变化都重置计时器,停止变化 100ms 后执行
1586
2012
  // (修复:此前新计时器未赋回 timer,clearTimeout 永远只清除首个 500ms
1587
2013
  // 计时器,防抖实际失效,每次 DOM 变化都会调度一次 sync)
2014
+ const now = Date.now()
2015
+ if (!pendingSince) pendingSince = now
1588
2016
  clearTimeout(timer)
1589
- timer = setTimeout(sync, 100)
2017
+ const waited = now - pendingSince
2018
+ timer = setTimeout(() => {
2019
+ pendingSince = 0
2020
+ sync()
2021
+ }, waited >= MAX_WAIT_MS ? 0 : 100)
1590
2022
  })
1591
2023
 
1592
2024
  observer.observe(document.body, {
@@ -1598,6 +2030,11 @@ window.__ModuleLoader__.load({
1598
2030
  disposed = true
1599
2031
  clearTimeout(timer)
1600
2032
  observer.disconnect()
2033
+ // 先还原图标内容,再清 marker:顺序反了会留下「无标记但已被改画」的节点
2034
+ for (const [svg, nodes] of originalIcons) {
2035
+ if (svg && typeof svg.replaceChildren === 'function') svg.replaceChildren(...nodes)
2036
+ }
2037
+ originalIcons.clear()
1601
2038
  document.querySelectorAll(`[${SOUL_NAV_MARKER}]`).forEach((element) => {
1602
2039
  element.removeAttribute(SOUL_NAV_MARKER)
1603
2040
  })