dsh-soul 0.7.0 → 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/LICENSE +21 -0
- package/README.md +32 -19
- package/README_EN.md +36 -19
- package/client/index.mjs +263 -74
- package/index.mjs +425 -76
- package/lib/config.mjs +42 -6
- package/lib/injection.mjs +2 -2
- package/lib/personas.mjs +34 -22
- package/lib/store.mjs +125 -0
- package/package.json +11 -2
- package/scripts/lib/skip-report.mjs +73 -0
- package/scripts/verify-client.mjs +700 -0
- package/scripts/verify-compat.mjs +58 -9
- package/scripts/verify-config.mjs +901 -34
- package/scripts/verify-e2e-prompt.mjs +627 -0
- package/scripts/verify-live-prompt.mjs +445 -0
- package/scripts/verify-nav-icon.mjs +35 -7
- package/scripts/verify-store.mjs +310 -0
- package/scripts/verify-trail.mjs +21 -8
package/client/index.mjs
CHANGED
|
@@ -3,12 +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
13
|
// 光轨另注册到 conversation.input.overlay 槽位(输入框卡片内部),
|
|
14
14
|
// 运行时按会话 running 状态在卡片上挂载/隐藏 SVG 环,见 SoulTrail。
|
|
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
// -------------------------------------------------------------------------
|
|
49
49
|
|
|
50
50
|
const NS = 'soul'
|
|
51
|
-
const VERSION = '0.7.
|
|
51
|
+
const VERSION = '0.7.1'
|
|
52
52
|
const INITIAL = {
|
|
53
53
|
enabled: true,
|
|
54
54
|
nickname: '',
|
|
@@ -71,11 +71,10 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
// 人设预设库与当前匹配项(GET /api/soul/personas 带回)
|
|
72
72
|
personas: null,
|
|
73
73
|
activePersona: null,
|
|
74
|
-
|
|
74
|
+
// 预设列表请求失败的原因(成功时清空)。用于把「加载中」与「加载失败」区分开
|
|
75
|
+
personasError: null,
|
|
75
76
|
saving: false,
|
|
76
|
-
error: null
|
|
77
|
-
// 最近一次保存实际变化的字段名数组(来自写路径的 changed)
|
|
78
|
-
lastChanged: null
|
|
77
|
+
error: null
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
// 第三方插件不能 require('@deepseek-ai/dsh-client-runtime/client')——
|
|
@@ -91,13 +90,43 @@ window.__ModuleLoader__.load({
|
|
|
91
90
|
update: (mutator) => {
|
|
92
91
|
const next = { ...state }
|
|
93
92
|
mutator(next)
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// 值全等时不换引用、也不通知:设置页每 2s 轮询一次宿主配置,绝大多数轮次
|
|
94
|
+
// 什么都没变,此前却每次都让订阅方(设置页 / 快捷开关 / 光轨)白重渲染一次。
|
|
95
|
+
// 取两侧键集的并集比较:只遍历 next 会漏掉「mutator 删了某个键」的情形,
|
|
96
|
+
// 那会变成静默不更新 —— 比多通知一次严重得多。
|
|
97
|
+
for (const key of new Set([...Object.keys(state), ...Object.keys(next)])) {
|
|
98
|
+
if (!Object.is(next[key], state[key])) {
|
|
99
|
+
state = next
|
|
100
|
+
notify()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
}
|
|
96
104
|
},
|
|
97
105
|
set: (next) => { state = next; notify() },
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
// 一层浅比较:用于判断服务端新解析出的对象是否与已存的等价。
|
|
110
|
+
// 预设条目本身是扁平对象(人格字段 + updatedAt / builtin),所以一层足够。
|
|
111
|
+
const shallowEqual = (a, b) => {
|
|
112
|
+
if (a === b) return true
|
|
113
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false
|
|
114
|
+
const ka = Object.keys(a)
|
|
115
|
+
const kb = Object.keys(b)
|
|
116
|
+
if (ka.length !== kb.length) return false
|
|
117
|
+
return ka.every((key) => Object.is(a[key], b[key]))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 预设库是「库 → 条目」两层结构,需要逐条目比。
|
|
121
|
+
const samePersonas = (a, b) => {
|
|
122
|
+
if (a === b) return true
|
|
123
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false
|
|
124
|
+
const ka = Object.keys(a)
|
|
125
|
+
const kb = Object.keys(b)
|
|
126
|
+
if (ka.length !== kb.length) return false
|
|
127
|
+
return ka.every((key) => shallowEqual(a[key], b[key]))
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
function hostBase() {
|
|
102
131
|
const origin = globalThis.location?.origin
|
|
103
132
|
return origin !== void 0 && origin !== 'null' ? origin : 'http://dsh.internal'
|
|
@@ -362,18 +391,25 @@ window.__ModuleLoader__.load({
|
|
|
362
391
|
|
|
363
392
|
async loadConfig() {
|
|
364
393
|
if (this.disposed) return
|
|
365
|
-
|
|
394
|
+
// 开始读取就清掉上一次的错误;此前还写了一个 s.loading,但全客户端零处读取
|
|
395
|
+
// (设置页首帧直接显示 INITIAL 默认值),属于只写不读的死状态,已删。
|
|
396
|
+
this.store.update(s => { s.error = null })
|
|
366
397
|
|
|
367
398
|
try {
|
|
368
399
|
const payload = await this.postJSON('/api/soul/config', null, { method: 'GET' })
|
|
369
400
|
this.store.update(s => {
|
|
370
401
|
this.applyConfig(s, payload.config)
|
|
371
|
-
|
|
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)
|
|
372
409
|
})
|
|
373
410
|
} catch (error) {
|
|
374
411
|
if (this.disposed) return
|
|
375
412
|
this.store.update(s => {
|
|
376
|
-
s.loading = false
|
|
377
413
|
s.error = messageOf(error)
|
|
378
414
|
})
|
|
379
415
|
}
|
|
@@ -387,7 +423,8 @@ window.__ModuleLoader__.load({
|
|
|
387
423
|
const payload = await this.postJSON('/api/soul/config', config)
|
|
388
424
|
this.store.update(s => {
|
|
389
425
|
this.applyConfig(s, payload.config)
|
|
390
|
-
|
|
426
|
+
// 保存成功也可能带着送达警告(配置写下了但送不到会话)
|
|
427
|
+
s.error = typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null
|
|
391
428
|
s.saving = false
|
|
392
429
|
})
|
|
393
430
|
return payload
|
|
@@ -408,7 +445,7 @@ window.__ModuleLoader__.load({
|
|
|
408
445
|
const payload = await this.postJSON('/api/soul/config/reset')
|
|
409
446
|
this.store.update(s => {
|
|
410
447
|
this.applyConfig(s, payload.config)
|
|
411
|
-
s.
|
|
448
|
+
s.error = typeof payload.deliveryWarning === 'string' ? payload.deliveryWarning : null
|
|
412
449
|
s.saving = false
|
|
413
450
|
})
|
|
414
451
|
return payload
|
|
@@ -435,7 +472,9 @@ window.__ModuleLoader__.load({
|
|
|
435
472
|
if (!this.disposed) {
|
|
436
473
|
this.store.update(s => {
|
|
437
474
|
this.applyConfig(s, payload.config)
|
|
438
|
-
|
|
475
|
+
// 清掉上一轮的失败提示:该端点不返回 deliveryWarning,此前一旦上一轮
|
|
476
|
+
// 保存失败过,切预设成功后红色错误条会一直挂着。
|
|
477
|
+
s.error = null
|
|
439
478
|
})
|
|
440
479
|
}
|
|
441
480
|
return payload
|
|
@@ -468,7 +507,7 @@ window.__ModuleLoader__.load({
|
|
|
468
507
|
}
|
|
469
508
|
|
|
470
509
|
// 预览「未保存的编辑」编译出的提示词:POST 草稿字段,服务端以已保存配置为底
|
|
471
|
-
//
|
|
510
|
+
// 覆盖后编译。不落盘、不影响生效提示词 —— 返回的 invalid 列出未通过校验、因而未计入
|
|
472
511
|
// 预览的字段名,供界面如实提示,避免用户以为改动已生效。
|
|
473
512
|
async previewPrompt(draft) {
|
|
474
513
|
const payload = await this.postJSON('/api/soul/prompt/preview', draft)
|
|
@@ -491,6 +530,9 @@ window.__ModuleLoader__.load({
|
|
|
491
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}',
|
|
492
531
|
'.soul-field{margin-bottom:16px;width:100%}',
|
|
493
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)}',
|
|
494
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}',
|
|
495
537
|
'.soul-field select option{color:var(--dsw-alias-label-primary)}',
|
|
496
538
|
'.soul-section{color-scheme:light}',
|
|
@@ -526,6 +568,9 @@ window.__ModuleLoader__.load({
|
|
|
526
568
|
'.soul-quick-toggle > *{position:relative;z-index:1}',
|
|
527
569
|
'.soul-quick-toggle-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}',
|
|
528
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}',
|
|
529
574
|
'.soul-quick-toggle:disabled{cursor:wait;opacity:.6}',
|
|
530
575
|
'@keyframes soul-toggle-red-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
|
|
531
576
|
'@keyframes soul-toggle-green-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
|
|
@@ -536,13 +581,40 @@ window.__ModuleLoader__.load({
|
|
|
536
581
|
'.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
|
|
537
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)}',
|
|
538
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}',
|
|
539
587
|
'.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
540
588
|
'.soul-dirty{color:#d46b08}',
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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}',
|
|
546
618
|
'.soul-persona-save{display:flex;gap:8px}',
|
|
547
619
|
'.soul-persona-save input[type=text]{flex:1}',
|
|
548
620
|
'.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
|
|
@@ -595,7 +667,12 @@ window.__ModuleLoader__.load({
|
|
|
595
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)}',
|
|
596
668
|
'.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
|
|
597
669
|
'.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
598
|
-
|
|
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}}'
|
|
599
676
|
].join('')
|
|
600
677
|
|
|
601
678
|
const tagId = 'dsh-soul/styles.css'
|
|
@@ -624,6 +701,8 @@ window.__ModuleLoader__.load({
|
|
|
624
701
|
'settings.title': '个性化设置',
|
|
625
702
|
'accordion.filled': '项已填写',
|
|
626
703
|
'accordion.empty': '未填写',
|
|
704
|
+
// 分组摘要在「拉取失败」时的显示(折叠态下用户只看得到这一行)
|
|
705
|
+
'accordion.loadFailed': '加载失败',
|
|
627
706
|
'accordion.personas': '个预设',
|
|
628
707
|
'accordion.on': '已开启',
|
|
629
708
|
'accordion.off': '未开启',
|
|
@@ -632,6 +711,8 @@ window.__ModuleLoader__.load({
|
|
|
632
711
|
'quick.disable': '关闭个性化',
|
|
633
712
|
'quick.statusEnabled': '个性化已启用',
|
|
634
713
|
'quick.statusDisabled': '个性化已关闭',
|
|
714
|
+
// 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
|
|
715
|
+
'quick.toggleFailed': '保存失败,请重试',
|
|
635
716
|
'group.aboutYou': '关于你',
|
|
636
717
|
'field.nickname': '用户昵称',
|
|
637
718
|
'field.nicknamePlaceholder': '输入你的昵称,回复时会称呼你',
|
|
@@ -662,8 +743,10 @@ window.__ModuleLoader__.load({
|
|
|
662
743
|
'personas.delete': '删除',
|
|
663
744
|
'personas.confirmDelete': '确定删除预设「{name}」?',
|
|
664
745
|
'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
|
|
746
|
+
// 列表拉取失败(此前是空 catch ⇒ 界面会永远停在「加载中...」)
|
|
747
|
+
'personas.loadFailed': '人设预设加载失败:{reason}',
|
|
665
748
|
'personas.builtin': '内置',
|
|
666
|
-
'personas.hint': '
|
|
749
|
+
'personas.hint': '预设只保存 Agent 的人设(风格 / 特质 / 回复长度 / 输出语言 / 自定义指令),不含「关于你」;内置预设随插件提供,不可删除',
|
|
667
750
|
'style.professional': '专业严谨',
|
|
668
751
|
'style.casual': '轻松自然',
|
|
669
752
|
'style.humorous': '幽默风趣',
|
|
@@ -703,12 +786,14 @@ window.__ModuleLoader__.load({
|
|
|
703
786
|
'toast.saved': '✅ 设置已保存',
|
|
704
787
|
'toast.noChanges': '✅ 配置无变化',
|
|
705
788
|
'toast.reset': '✅ 已重置为默认值',
|
|
789
|
+
'toast.resetRecovered': '✅ 已重置为默认值(损坏的配置已备份)',
|
|
790
|
+
'toast.resetFailed': '❌ 重置失败',
|
|
706
791
|
'toast.personaSaved': '✅ 预设已保存',
|
|
707
792
|
'toast.personaUsed': '✅ 预设已应用',
|
|
793
|
+
// 预设含非法取值时服务端只应用合法部分,被跳过的字段必须回报
|
|
794
|
+
'toast.personaPartial': '⚠ 预设仅部分应用,以下字段取值非法已跳过:{fields}',
|
|
708
795
|
'toast.personaUnchanged': 'ℹ 预设与当前配置一致',
|
|
709
796
|
'toast.personaDeleted': '✅ 预设已删除',
|
|
710
|
-
'prompt.view': '查看当前生效提示词',
|
|
711
|
-
'prompt.hide': '收起提示词',
|
|
712
797
|
'prompt.title': '当前生效提示词',
|
|
713
798
|
'prompt.summaryEnabled': '已启用',
|
|
714
799
|
'prompt.summaryDisabled': '已禁用',
|
|
@@ -719,6 +804,9 @@ window.__ModuleLoader__.load({
|
|
|
719
804
|
'prompt.titleDraft': '保存后将生效的提示词',
|
|
720
805
|
'prompt.summaryDraft': '{n} 字符 · 未保存',
|
|
721
806
|
'prompt.invalid': '以下字段未通过校验,未计入本次预览:{fields}',
|
|
807
|
+
// 列表分隔符:中文用顿号、英文用逗号 + 空格。此前英文界面里也硬编码「、」,
|
|
808
|
+
// 会渲染成 `...excluded from this preview: style、tables`(中英混排)。
|
|
809
|
+
'common.listSep': '、',
|
|
722
810
|
'toast.saveFailed': '❌ 保存失败,请检查配置',
|
|
723
811
|
'trail.title': '输入框光轨',
|
|
724
812
|
'trail.enable': '启用输入框光轨',
|
|
@@ -741,6 +829,8 @@ window.__ModuleLoader__.load({
|
|
|
741
829
|
'settings.title': 'Personalization Settings',
|
|
742
830
|
'accordion.filled': 'fields filled',
|
|
743
831
|
'accordion.empty': 'No fields set',
|
|
832
|
+
// 分组摘要在「拉取失败」时的显示(折叠态下用户只看得到这一行)
|
|
833
|
+
'accordion.loadFailed': 'Failed to load',
|
|
744
834
|
'accordion.personas': 'personas',
|
|
745
835
|
'accordion.on': 'enabled',
|
|
746
836
|
'accordion.off': 'disabled',
|
|
@@ -749,6 +839,8 @@ window.__ModuleLoader__.load({
|
|
|
749
839
|
'quick.disable': 'Disable personalization',
|
|
750
840
|
'quick.statusEnabled': 'Personalization enabled',
|
|
751
841
|
'quick.statusDisabled': 'Personalization disabled',
|
|
842
|
+
// 快捷开关保存失败时的兜底文案(有服务端 error 时优先显示它)
|
|
843
|
+
'quick.toggleFailed': 'Save failed, please retry',
|
|
752
844
|
'group.aboutYou': 'About you',
|
|
753
845
|
'field.nickname': 'Nickname',
|
|
754
846
|
'field.nicknamePlaceholder': 'Your nickname — the agent will address you by it',
|
|
@@ -779,8 +871,10 @@ window.__ModuleLoader__.load({
|
|
|
779
871
|
'personas.delete': 'Delete',
|
|
780
872
|
'personas.confirmDelete': 'Delete persona "{name}"?',
|
|
781
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}',
|
|
782
876
|
'personas.builtin': 'Built-in',
|
|
783
|
-
'personas.hint': '
|
|
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',
|
|
784
878
|
'style.professional': 'Professional',
|
|
785
879
|
'style.casual': 'Casual',
|
|
786
880
|
'style.humorous': 'Humorous',
|
|
@@ -820,12 +914,14 @@ window.__ModuleLoader__.load({
|
|
|
820
914
|
'toast.saved': '✅ Settings saved',
|
|
821
915
|
'toast.noChanges': '✅ No changes to save',
|
|
822
916
|
'toast.reset': '✅ Reset to defaults',
|
|
917
|
+
'toast.resetRecovered': '✅ Reset to defaults (the damaged config was backed up)',
|
|
918
|
+
'toast.resetFailed': '❌ Reset failed',
|
|
823
919
|
'toast.personaSaved': '✅ Persona saved',
|
|
824
920
|
'toast.personaUsed': '✅ Persona applied',
|
|
921
|
+
// 预设含非法取值时服务端只应用合法部分,被跳过的字段必须回报
|
|
922
|
+
'toast.personaPartial': '⚠ Persona applied partially; skipped as invalid: {fields}',
|
|
825
923
|
'toast.personaUnchanged': 'ℹ Persona matches current config',
|
|
826
924
|
'toast.personaDeleted': '✅ Persona deleted',
|
|
827
|
-
'prompt.view': 'View the active system prompt',
|
|
828
|
-
'prompt.hide': 'Hide the prompt',
|
|
829
925
|
'prompt.title': 'Active system prompt',
|
|
830
926
|
'prompt.summaryEnabled': 'Enabled',
|
|
831
927
|
'prompt.summaryDisabled': 'Disabled',
|
|
@@ -836,6 +932,9 @@ window.__ModuleLoader__.load({
|
|
|
836
932
|
'prompt.titleDraft': 'Prompt after saving',
|
|
837
933
|
'prompt.summaryDraft': '{n} chars · unsaved',
|
|
838
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': ', ',
|
|
839
938
|
'toast.saveFailed': '❌ Save failed — please check the config',
|
|
840
939
|
'trail.title': 'Composer light trail',
|
|
841
940
|
'trail.enable': 'Enable composer light trail',
|
|
@@ -913,9 +1012,12 @@ window.__ModuleLoader__.load({
|
|
|
913
1012
|
// 编译结果,不该因此重新请求,也不该让预览被标成「未保存」。
|
|
914
1013
|
const PROMPT_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
|
|
915
1014
|
|
|
916
|
-
// 提示词小图标:hover
|
|
1015
|
+
// 提示词小图标:hover / 键盘聚焦都展示说明文字。
|
|
1016
|
+
// 此前只有 :hover 可达,且提示节点用 visibility:hidden —— 那会把它移出无障碍树,
|
|
1017
|
+
// 键盘与读屏用户拿不到字段说明。这里补 tabIndex 让它可以聚焦,并在 :focus-visible
|
|
1018
|
+
// 下显示提示(同时保留 aria-label,聚焦时读屏会念出说明本身)。
|
|
917
1019
|
function SoulHint(props) {
|
|
918
|
-
return e('span', { className: 'soul-hint', 'aria-label': props.text },
|
|
1020
|
+
return e('span', { className: 'soul-hint', 'aria-label': props.text, tabIndex: 0 },
|
|
919
1021
|
e('svg', {
|
|
920
1022
|
viewBox: '0 0 24 24',
|
|
921
1023
|
width: 14,
|
|
@@ -962,18 +1064,28 @@ window.__ModuleLoader__.load({
|
|
|
962
1064
|
const enabled = state.enabled === true
|
|
963
1065
|
const busy = state.saving === true
|
|
964
1066
|
|
|
1067
|
+
// 保存失败的本地标记:失败时 store.error 会带上原因,但按钮本身此前毫无反馈
|
|
1068
|
+
// (enabled 没变 ⇒ 外观与文案都不变),用户视角是「点了没反应」。
|
|
1069
|
+
const [failed, setFailed] = React.useState(false)
|
|
1070
|
+
const failureText = failed ? (state.error || t('quick.toggleFailed')) : null
|
|
1071
|
+
|
|
965
1072
|
const toggle = async () => {
|
|
966
1073
|
if (busy) return
|
|
967
|
-
|
|
1074
|
+
setFailed(false)
|
|
1075
|
+
const payload = await controller.saveConfig({ enabled: !enabled })
|
|
1076
|
+
// saveConfig 失败时返回 undefined;成功时返回载荷
|
|
1077
|
+
setFailed(!payload)
|
|
968
1078
|
}
|
|
969
1079
|
|
|
970
1080
|
return e('button', {
|
|
971
1081
|
type: 'button',
|
|
972
|
-
className: 'soul-quick-toggle',
|
|
1082
|
+
className: failed ? 'soul-quick-toggle soul-quick-toggle-failed' : 'soul-quick-toggle',
|
|
973
1083
|
'data-enabled': enabled ? 'true' : 'false',
|
|
1084
|
+
'data-failed': failed ? 'true' : 'false',
|
|
974
1085
|
'aria-pressed': enabled,
|
|
975
|
-
|
|
976
|
-
|
|
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')),
|
|
977
1089
|
disabled: busy,
|
|
978
1090
|
onClick: toggle
|
|
979
1091
|
},
|
|
@@ -987,7 +1099,7 @@ window.__ModuleLoader__.load({
|
|
|
987
1099
|
// 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
|
|
988
1100
|
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
989
1101
|
const state = useSoulController((state) => state)
|
|
990
|
-
const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona,
|
|
1102
|
+
const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, personasError, saving, error } = state
|
|
991
1103
|
|
|
992
1104
|
const [localEnabled, setLocalEnabled] = React.useState(enabled)
|
|
993
1105
|
const [localNickname, setLocalNickname] = React.useState(nickname || '')
|
|
@@ -1033,7 +1145,14 @@ window.__ModuleLoader__.load({
|
|
|
1033
1145
|
}, [openSection])
|
|
1034
1146
|
|
|
1035
1147
|
// 同步状态
|
|
1148
|
+
// 用 store 回填本地表单。
|
|
1149
|
+
//
|
|
1150
|
+
// **dirty 期间不回填**:否则任何客户端侧对共享 store 的写入(例如快捷开关成功后
|
|
1151
|
+
// 的 applyConfig)都会让依赖变化、本 effect 重跑,把用户正在编辑、尚未保存的
|
|
1152
|
+
// 内容整片覆盖 —— 且没有任何提示。轮询路径(下方 refresh)已有 `if (dirty ||
|
|
1153
|
+
// saving) return` 的保护,这里此前漏了同一道保护。
|
|
1036
1154
|
React.useEffect(() => {
|
|
1155
|
+
if (dirty || saving) return
|
|
1037
1156
|
setLocalEnabled(enabled)
|
|
1038
1157
|
setLocalNickname(nickname || '')
|
|
1039
1158
|
setLocalOccupation(occupation || '')
|
|
@@ -1060,15 +1179,25 @@ window.__ModuleLoader__.load({
|
|
|
1060
1179
|
}
|
|
1061
1180
|
}, [toast])
|
|
1062
1181
|
|
|
1063
|
-
// dirty
|
|
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)
|
|
1064
1191
|
const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
|
|
1065
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 }
|
|
1066
|
-
const dirty = FIELD_KEYS.some((key) => savedMap[key]
|
|
1193
|
+
const dirty = FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
|
|
1067
1194
|
|
|
1068
1195
|
// 预览是否反映未保存的编辑:只看参与编译的字段(见 PROMPT_FIELD_KEYS)。
|
|
1069
1196
|
// 这里用 localMap/savedMap 逐字段比,而不是复用上面的 dirty —— 改光轨颜色会让
|
|
1070
1197
|
// dirty 为真,但那不影响提示词,此时预览仍是「已生效内容」,标成草稿反而误导。
|
|
1071
|
-
|
|
1198
|
+
// 必须与 dirty 用**同一个** sameValue 判据:否则「只改了首尾空格」时 dirty 为假
|
|
1199
|
+
// (保存按钮禁用)而 promptDirty 为真(预览标成草稿),两处自相矛盾。
|
|
1200
|
+
const promptDirty = PROMPT_FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
|
|
1072
1201
|
// 草稿指纹:仅在影响编译的字段真的变化时才重新预览
|
|
1073
1202
|
const promptDraftKey = PROMPT_FIELD_KEYS.map((key) => String(localMap[key])).join('\u0000')
|
|
1074
1203
|
|
|
@@ -1079,7 +1208,7 @@ window.__ModuleLoader__.load({
|
|
|
1079
1208
|
return out
|
|
1080
1209
|
}
|
|
1081
1210
|
|
|
1082
|
-
//
|
|
1211
|
+
// 有未保存编辑时预览草稿(=保存后真正会生效的内容),否则预览已生效提示词
|
|
1083
1212
|
const loadPrompt = async () => {
|
|
1084
1213
|
const asDraft = promptDirty
|
|
1085
1214
|
setPromptLoading(true)
|
|
@@ -1105,11 +1234,20 @@ window.__ModuleLoader__.load({
|
|
|
1105
1234
|
try {
|
|
1106
1235
|
const payload = await controller.fetchPersonas()
|
|
1107
1236
|
controller.store.update((s) => {
|
|
1108
|
-
|
|
1237
|
+
// 内容相同就保留旧引用:每 2s 轮询都会重新解析出**新对象**,若直接赋值,
|
|
1238
|
+
// store 的浅比较永远判为「变了」⇒ 上面那层去重失效,整页仍每 2s 白重渲染。
|
|
1239
|
+
s.personas = samePersonas(s.personas, payload.personas) ? s.personas : payload.personas
|
|
1109
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)
|
|
1110
1250
|
})
|
|
1111
|
-
} catch {
|
|
1112
|
-
// 预设列表加载失败不打断主配置界面
|
|
1113
1251
|
}
|
|
1114
1252
|
}
|
|
1115
1253
|
|
|
@@ -1137,7 +1275,7 @@ window.__ModuleLoader__.load({
|
|
|
1137
1275
|
|
|
1138
1276
|
// 提示词预览的编译入口(唯一)。
|
|
1139
1277
|
// 展开时立即编译一次;此后只要影响编译的字段或脏状态变化,停止 400ms 再编译 —
|
|
1140
|
-
//
|
|
1278
|
+
// 边改边看保存后真正会生效的内容。
|
|
1141
1279
|
// promptSettled 用来区分「刚展开」与「展开后字段又变了」:否则展开这一动作自身
|
|
1142
1280
|
// 会先立即编译一次、再被防抖重复编译一次。
|
|
1143
1281
|
// 依赖里刻意不放 loadPrompt(组件体内每次渲染都会重建函数,放进去会无限循环);
|
|
@@ -1173,8 +1311,18 @@ window.__ModuleLoader__.load({
|
|
|
1173
1311
|
}
|
|
1174
1312
|
|
|
1175
1313
|
const handleReset = async () => {
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
+
})
|
|
1178
1326
|
// 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
|
|
1179
1327
|
// promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
|
|
1180
1328
|
}
|
|
@@ -1196,7 +1344,14 @@ window.__ModuleLoader__.load({
|
|
|
1196
1344
|
try {
|
|
1197
1345
|
const payload = await controller.usePersona(name)
|
|
1198
1346
|
await loadPersonas()
|
|
1199
|
-
|
|
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)
|
|
1200
1355
|
// 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
|
|
1201
1356
|
// promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
|
|
1202
1357
|
} catch (err) {
|
|
@@ -1232,7 +1387,10 @@ window.__ModuleLoader__.load({
|
|
|
1232
1387
|
const aboutFilled = [localNickname, localOccupation, localBio].filter(Boolean).length
|
|
1233
1388
|
const aboutSummary = aboutFilled > 0 ? `${aboutFilled}/3 ${t('accordion.filled')}` : t('accordion.empty')
|
|
1234
1389
|
const traitsSummary = t(`style.${localStyle}`)
|
|
1235
|
-
|
|
1390
|
+
// 失败时摘要也要区分(折叠状态下用户只看得到这一行)
|
|
1391
|
+
const personasSummary = personasError
|
|
1392
|
+
? t('accordion.loadFailed')
|
|
1393
|
+
: (personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`)
|
|
1236
1394
|
const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
|
|
1237
1395
|
const promptSummary = promptText.length > 0
|
|
1238
1396
|
? t(promptIsDraft ? 'prompt.summaryDraft' : 'prompt.summaryChars', { n: String(promptText.length) })
|
|
@@ -1241,15 +1399,15 @@ window.__ModuleLoader__.load({
|
|
|
1241
1399
|
setOpenSection(current => current === section ? null : section)
|
|
1242
1400
|
}
|
|
1243
1401
|
|
|
1244
|
-
// 预设行摘要:风格 +
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1402
|
+
// 预设行摘要:风格 + 所有「偏离默认值」的人设维度。
|
|
1403
|
+
// 预设只描述 Agent 的人格,不含「关于你」(昵称 / 职业 / 介绍)—— 那是使用者
|
|
1404
|
+
// 的身份信息,不随预设切换。所以摘要里不再出现昵称,剩下的恰好也是比较两个
|
|
1405
|
+
// 预设时最需要看到的信息。
|
|
1247
1406
|
const personaRowMeta = (entry) => {
|
|
1248
1407
|
const row = entry || {}
|
|
1249
1408
|
const parts = []
|
|
1250
1409
|
if (STYLE_VALUES.includes(row.style)) parts.push(t(`style.${row.style}`))
|
|
1251
1410
|
else if (row.style) parts.push(row.style)
|
|
1252
|
-
if (row.nickname) parts.push(row.nickname)
|
|
1253
1411
|
for (const [field, allowed] of Object.entries(META_FIELD_VALUES)) {
|
|
1254
1412
|
const value = row[field]
|
|
1255
1413
|
if (!value || value === META_FIELD_DEFAULTS[field]) continue
|
|
@@ -1421,27 +1579,38 @@ window.__ModuleLoader__.load({
|
|
|
1421
1579
|
onToggle: toggleSection('personas')
|
|
1422
1580
|
},
|
|
1423
1581
|
e('div', { className: 'soul-field' },
|
|
1424
|
-
|
|
1582
|
+
// 加载失败优先于「加载中」:否则失败时界面永远停在加载态,与「还没有预设」无从区分
|
|
1583
|
+
personasError
|
|
1584
|
+
? e('div', { className: 'soul-error' }, t('personas.loadFailed', { reason: personasError }))
|
|
1585
|
+
: (personaNames === null
|
|
1425
1586
|
? e('div', { className: 'soul-status' }, t('status.loading'))
|
|
1426
1587
|
: (personaNames.length === 0
|
|
1427
1588
|
? e('div', { className: 'soul-status' }, t('personas.empty'))
|
|
1428
1589
|
: e(Fragment, null,
|
|
1429
1590
|
...personaNames.map((name) => {
|
|
1430
1591
|
const entry = personas[name] || {}
|
|
1431
|
-
// 内置预设不提供删除(后端也会拒绝),在 actions 区原删除按钮的
|
|
1432
|
-
// 位置显示「内置」标记,行宽因此不会跳动。
|
|
1433
1592
|
const builtin = !!entry.builtin
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
+
),
|
|
1436
1601
|
e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
|
|
1437
1602
|
e('span', { className: 'soul-persona-actions' },
|
|
1438
|
-
builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin')),
|
|
1439
1603
|
e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
|
|
1440
|
-
|
|
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'))
|
|
1441
1609
|
)
|
|
1442
1610
|
)
|
|
1443
1611
|
})
|
|
1444
1612
|
))
|
|
1613
|
+
)
|
|
1445
1614
|
),
|
|
1446
1615
|
e('div', { className: 'soul-field soul-persona-save' },
|
|
1447
1616
|
e('input', {
|
|
@@ -1542,9 +1711,10 @@ window.__ModuleLoader__.load({
|
|
|
1542
1711
|
)
|
|
1543
1712
|
),
|
|
1544
1713
|
e('div', { className: 'soul-field' },
|
|
1545
|
-
|
|
1714
|
+
// 预览示例不是表单控件,没有可关联的 id —— 用 label 会是「指向空的标签」,
|
|
1715
|
+
// 所以改用同风格的 div(样式见 .soul-field-label)
|
|
1716
|
+
e('div', { className: 'soul-field-label' }, t('trail.preview')),
|
|
1546
1717
|
e(SoulTrailPreview, {
|
|
1547
|
-
enabled: localTrailEnabled,
|
|
1548
1718
|
color: localTrailColor,
|
|
1549
1719
|
speed: localTrailSpeed,
|
|
1550
1720
|
width: localTrailWidth,
|
|
@@ -1570,7 +1740,7 @@ window.__ModuleLoader__.load({
|
|
|
1570
1740
|
e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
|
|
1571
1741
|
e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) })),
|
|
1572
1742
|
promptInvalid.length > 0 && e('div', { className: 'soul-error' },
|
|
1573
|
-
t('prompt.invalid', { fields: promptInvalid.join('
|
|
1743
|
+
t('prompt.invalid', { fields: promptInvalid.join(t('common.listSep')) }))
|
|
1574
1744
|
))
|
|
1575
1745
|
),
|
|
1576
1746
|
|
|
@@ -1592,6 +1762,10 @@ window.__ModuleLoader__.load({
|
|
|
1592
1762
|
|
|
1593
1763
|
toast && e('div', {
|
|
1594
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',
|
|
1595
1769
|
style: {
|
|
1596
1770
|
animation: 'fadeInOut 2s ease-in-out'
|
|
1597
1771
|
}
|
|
@@ -1605,33 +1779,29 @@ window.__ModuleLoader__.load({
|
|
|
1605
1779
|
|
|
1606
1780
|
// 设置页内的实时示例:与线上光轨共用同一渲染层、同一挂载点解析(resolveTrailMountPoint)
|
|
1607
1781
|
// 与同一份 CSS,因此「示例所见」即「回复时所得」;颜色/速度/粗细随表单实时联动。
|
|
1782
|
+
//
|
|
1783
|
+
// 本组件**只在光轨开启时**被渲染(见调用处的 `localTrailEnabled &&`),所以它没有
|
|
1784
|
+
// 「关闭」这一态:此前它接了一个 `enabled` prop 并写了 off 分支,那是永不执行的死
|
|
1785
|
+
// 代码(且 `data-soul-trail="off"` 会连带影响 CSS 选择器),现在去掉。
|
|
1608
1786
|
function SoulTrailPreview(props) {
|
|
1609
1787
|
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
1610
1788
|
const hostRef = React.useRef(null)
|
|
1611
1789
|
const anchorRef = React.useRef(null)
|
|
1612
|
-
const mountedRef = React.useRef(null)
|
|
1613
1790
|
|
|
1614
1791
|
React.useEffect(() => {
|
|
1615
1792
|
const host = hostRef.current
|
|
1616
1793
|
if (!host) return undefined
|
|
1617
1794
|
// 与线上 SoulTrail 同一条路径:解析出的挂载点即示例内的零高度锚点 .soul-trail-anchor
|
|
1618
1795
|
const mounted = mountTrailRing(host, resolveTrailMountPoint(host, anchorRef.current))
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
mounted.unmount()
|
|
1623
|
-
}
|
|
1796
|
+
// 环的内联样式以 display:none 起(见 mountTrailRing),必须显式显形一次
|
|
1797
|
+
mounted.setVisible(true)
|
|
1798
|
+
return () => mounted.unmount()
|
|
1624
1799
|
}, [])
|
|
1625
1800
|
|
|
1626
|
-
React.useEffect(() => {
|
|
1627
|
-
const mounted = mountedRef.current
|
|
1628
|
-
if (mounted) mounted.setVisible(props.enabled !== false)
|
|
1629
|
-
}, [props.enabled])
|
|
1630
|
-
|
|
1631
1801
|
return e('div', {
|
|
1632
1802
|
ref: hostRef,
|
|
1633
1803
|
className: 'soul-trail-preview',
|
|
1634
|
-
'data-soul-trail':
|
|
1804
|
+
'data-soul-trail': 'on',
|
|
1635
1805
|
'data-soul-trail-speed': props.speed || 'slow',
|
|
1636
1806
|
'data-soul-trail-width': props.width || 'thin',
|
|
1637
1807
|
style: { '--soul-trail-color': safeTrailColor(props.color) }
|
|
@@ -1799,6 +1969,9 @@ window.__ModuleLoader__.load({
|
|
|
1799
1969
|
|
|
1800
1970
|
const registerSettingsNavIcon = () => {
|
|
1801
1971
|
let disposed = false
|
|
1972
|
+
// 改画前记下宿主 svg 的原始子节点:此前卸载时只清 marker 属性、不还原内容,
|
|
1973
|
+
// 于是禁用插件 / HMR 之后导航栏仍显示插件图标,直到壳层重建那个按钮。
|
|
1974
|
+
const originalIcons = new Map()
|
|
1802
1975
|
|
|
1803
1976
|
const sync = () => {
|
|
1804
1977
|
if (disposed) return
|
|
@@ -1817,6 +1990,7 @@ window.__ModuleLoader__.load({
|
|
|
1817
1990
|
if (existingIcon) {
|
|
1818
1991
|
// 标记已替换;React 重建按钮时标记随之消失,会重新画一次
|
|
1819
1992
|
button.setAttribute(SOUL_ICON_MARKER, 'true')
|
|
1993
|
+
originalIcons.set(existingIcon, Array.from(existingIcon.childNodes))
|
|
1820
1994
|
paintSoulNavIcon(existingIcon)
|
|
1821
1995
|
}
|
|
1822
1996
|
} else {
|
|
@@ -1827,14 +2001,24 @@ window.__ModuleLoader__.load({
|
|
|
1827
2001
|
|
|
1828
2002
|
// 延迟执行初始同步,等待设置页面渲染完成
|
|
1829
2003
|
let timer = setTimeout(sync, 500)
|
|
2004
|
+
// 最长等待:防抖只该合并「一连串变化」,不该被无限推迟 —— 会话流式输出时
|
|
2005
|
+
// body 每帧都在变,纯 100ms 防抖会让 sync 永远排不到(图标一直不替换)。
|
|
2006
|
+
const MAX_WAIT_MS = 600
|
|
2007
|
+
let pendingSince = 0
|
|
1830
2008
|
|
|
1831
2009
|
// 监听 DOM 变化(只监听子节点添加,不监听所有变化)
|
|
1832
2010
|
const observer = new MutationObserver(() => {
|
|
1833
2011
|
// 防抖:每次变化都重置计时器,停止变化 100ms 后执行
|
|
1834
2012
|
// (修复:此前新计时器未赋回 timer,clearTimeout 永远只清除首个 500ms
|
|
1835
2013
|
// 计时器,防抖实际失效,每次 DOM 变化都会调度一次 sync)
|
|
2014
|
+
const now = Date.now()
|
|
2015
|
+
if (!pendingSince) pendingSince = now
|
|
1836
2016
|
clearTimeout(timer)
|
|
1837
|
-
|
|
2017
|
+
const waited = now - pendingSince
|
|
2018
|
+
timer = setTimeout(() => {
|
|
2019
|
+
pendingSince = 0
|
|
2020
|
+
sync()
|
|
2021
|
+
}, waited >= MAX_WAIT_MS ? 0 : 100)
|
|
1838
2022
|
})
|
|
1839
2023
|
|
|
1840
2024
|
observer.observe(document.body, {
|
|
@@ -1846,6 +2030,11 @@ window.__ModuleLoader__.load({
|
|
|
1846
2030
|
disposed = true
|
|
1847
2031
|
clearTimeout(timer)
|
|
1848
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()
|
|
1849
2038
|
document.querySelectorAll(`[${SOUL_NAV_MARKER}]`).forEach((element) => {
|
|
1850
2039
|
element.removeAttribute(SOUL_NAV_MARKER)
|
|
1851
2040
|
})
|