dsh-soul 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/index.mjs CHANGED
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
48
48
  // -------------------------------------------------------------------------
49
49
 
50
50
  const NS = 'soul'
51
- const VERSION = '0.7.2'
51
+ const VERSION = '0.8.0'
52
52
  const INITIAL = {
53
53
  enabled: true,
54
54
  nickname: '',
@@ -73,6 +73,35 @@ window.__ModuleLoader__.load({
73
73
  activePersona: null,
74
74
  // 预设列表请求失败的原因(成功时清空)。用于把「加载中」与「加载失败」区分开
75
75
  personasError: null,
76
+ // 变更记录(0.8.0):null = 还没拉过,[] = 拉过且确实没有记录。
77
+ // 两者必须分得开 —— 否则「没有记录」与「还没加载」在界面上是同一种空白。
78
+ history: null,
79
+ // 记录可能不完整的原因(读取窗口切掉、坏行、压实、写入失败),服务端给的原文
80
+ historyWarning: null,
81
+ historyError: null,
82
+ // 容量上限(服务端 HISTORY_MAX_ENTRIES)。由响应带回而不是写死在客户端:
83
+ // 两边各写一份迟早会不一致,而「最多保留 N 条」是一句会被人当真的承诺。
84
+ historyMax: null,
85
+ // 变更记录保留条数(配置字段,用户可改)。与 historyMax 不同:
86
+ // historyMax 是「当前配置承诺的上限」(响应带回),historyMaxEntries 是
87
+ // 设置表单里的那份值 —— 保存成功后两者应相等。
88
+ historyMaxEntries: 200,
89
+ // —— 工作区绑定(0.8.1)——
90
+ // 与 history 同款:null = 还没拉过,[] = 拉过且确实没有绑定。
91
+ // 「这个项目还没绑过」与「还没去看」是两件事,界面上不给它们同一种空白。
92
+ bindings: null,
93
+ // 失效引用(绑定的人设已不存在)与坏行丢弃数,都由服务端如实标注
94
+ bindingsInvalid: [],
95
+ bindingsDropped: null,
96
+ // 「记录可能不完整」的原因,不是失败(同 historyWarning)
97
+ bindingsWarning: null,
98
+ bindingsError: null,
99
+ // 服务端承诺的上限(MAX_BINDINGS / MAX_PATH_LENGTH),由响应带回,
100
+ // 不在客户端写死 —— 两边各写一份迟早会不一致
101
+ bindingsMax: null,
102
+ bindingsMaxPath: null,
103
+ // 活动会话的解析结果(只读状态:哪个目录现在用的是谁)
104
+ bindingsSessions: [],
76
105
  saving: false,
77
106
  error: null
78
107
  }
@@ -127,6 +156,16 @@ window.__ModuleLoader__.load({
127
156
  return ka.every((key) => shallowEqual(a[key], b[key]))
128
157
  }
129
158
 
159
+ // 变更记录只在「追加一条」或「被清空」时变化,两者的**id 序列**都不一样 ——
160
+ // 所以逐条深比是白费力气(每条含 before/after/personas,深比一次的代价远超收益)。
161
+ // 比 id 序列足够,且顺带覆盖了「压实后旧条目被淘汰」这种变化。
162
+ const sameHistory = (a, b) => {
163
+ if (a === b) return true
164
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
165
+ if (a.length !== b.length) return false
166
+ return a.every((item, index) => item && b[index] && item.id === b[index].id)
167
+ }
168
+
130
169
  function hostBase() {
131
170
  const origin = globalThis.location?.origin
132
171
  return origin !== void 0 && origin !== 'null' ? origin : 'http://dsh.internal'
@@ -398,6 +437,8 @@ window.__ModuleLoader__.load({
398
437
  s.trailColor = config.trailColor || TRAIL_COLOR_FALLBACK
399
438
  s.trailSpeed = config.trailSpeed || 'slow'
400
439
  s.trailWidth = config.trailWidth || 'thin'
440
+ // 变更记录保留条数:服务端 migrate 已夹取到 50–500,这里只兜「缺席」
441
+ s.historyMaxEntries = Number.isFinite(config.historyMaxEntries) ? config.historyMaxEntries : 200
401
442
  }
402
443
 
403
444
  async loadConfig() {
@@ -521,6 +562,141 @@ window.__ModuleLoader__.load({
521
562
  return await this.postJSON('/api/soul/personas/delete', { name })
522
563
  }
523
564
 
565
+ // —— 变更记录(0.8.0)——
566
+ async fetchHistory() {
567
+ return await this.postJSON('/api/soul/history', null, { method: 'GET' })
568
+ }
569
+
570
+ // 拉取记录并写入 store。
571
+ //
572
+ // 与 loadPersonas 同款:失败必须留下痕迹,否则界面永远停在「加载中」,
573
+ // 与「真的还没有记录」无从区分 —— 而「到底有没有记录」正是这一组的全部意义。
574
+ async loadHistory() {
575
+ if (this.disposed) return
576
+ try {
577
+ const payload = await this.fetchHistory()
578
+ this.store.update((s) => {
579
+ // 内容相同就保留旧引用,理由同 loadPersonas:每 2s 轮询都会解析出**新数组**,
580
+ // 直接赋值会让 store 的浅比较永远判「变了」,整页白重渲染。
581
+ const next = Array.isArray(payload.entries) ? payload.entries : []
582
+ s.history = sameHistory(s.history, next) ? s.history : next
583
+ // warning 是「记录可能不完整」,不是失败:不写进 historyError,
584
+ // 否则界面会把「只显示了最近 1000 条」报成红色错误。
585
+ s.historyWarning = payload.warning ? String(payload.warning) : null
586
+ s.historyMax = Number.isFinite(payload.max) ? payload.max : s.historyMax
587
+ s.historyError = null
588
+ })
589
+ } catch (err) {
590
+ this.store.update((s) => {
591
+ s.historyError = messageOf(err)
592
+ })
593
+ }
594
+ }
595
+
596
+ // 撤销:把配置退回到目标记录**发生之前**。
597
+ //
598
+ // 409(diverged)与 400(找不到 / 记录被压实淘汰)都是**预期内的拒绝**,
599
+ // 不是网络故障 —— 直接把服务端文案抛给调用方,由界面原样展示。
600
+ // 服务端已经把原因写得能读懂(「记录与当前配置对不上,可能有人手工改过配置文件」),
601
+ // 这里再包一层「撤销失败:...」只会把关键信息埋起来。
602
+ async rollbackHistory(id) {
603
+ const payload = await this.postJSON('/api/soul/history/rollback', { id })
604
+ if (!this.disposed) {
605
+ this.store.update((s) => {
606
+ this.applyConfig(s, payload.config)
607
+ s.error = null
608
+ })
609
+ }
610
+ return payload
611
+ }
612
+
613
+ async restorePersona(id) {
614
+ const payload = await this.postJSON('/api/soul/history/restore-persona', { id })
615
+ if (!this.disposed) {
616
+ this.store.update((s) => {
617
+ this.applyConfig(s, payload.config)
618
+ s.error = null
619
+ })
620
+ }
621
+ return payload
622
+ }
623
+
624
+ async clearHistory() {
625
+ return await this.postJSON('/api/soul/history/clear')
626
+ }
627
+
628
+ // —— 工作区绑定(0.8.1)——
629
+ async fetchBindings() {
630
+ return await this.postJSON('/api/soul/bindings', null, { method: 'GET' })
631
+ }
632
+
633
+ // 把服务端返回的绑定视图整块写进 store。
634
+ //
635
+ // 绑定的每个字段都是「服务端说了算」:失效引用由服务端按当前预设库算出,
636
+ // sessions 的生效人设由服务端走与真实装配同一条解析路径得出。客户端只负责
637
+ // 原样保留 —— 自己再算一遍只会有两种结果:算对了白算,算错了两个地方打架。
638
+ applyBindings(s, payload) {
639
+ // bindings 是**对象**(目录 → 人设名),不是数组 —— 服务端 bindingsView
640
+ // 直接透传 soul-bindings.json 的映射。上一版这里写的是 Array.isArray 兜底,
641
+ // 一旦为假就会把整张表静默吞成空:界面显示「还没有绑定任何目录」而文件里
642
+ // 其实有。故按对象判,且**缺字段时保留旧值**而不是清空。
643
+ const raw = payload.bindings
644
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) s.bindings = raw
645
+ else if (raw === null || Array.isArray(raw)) s.bindings = {}
646
+ s.bindingsInvalid = Array.isArray(payload.invalid) ? payload.invalid : []
647
+ s.bindingsDropped = Number.isFinite(payload.dropped) ? payload.dropped : 0
648
+ // warning 是「看到的不是全部」,不是失败,所以不写进 bindingsError
649
+ s.bindingsWarning = payload.warning ? String(payload.warning) : null
650
+ s.bindingsMax = Number.isFinite(payload.max) ? payload.max : s.bindingsMax
651
+ s.bindingsMaxPath = Number.isFinite(payload.maxPathLength)
652
+ ? payload.maxPathLength
653
+ : s.bindingsMaxPath
654
+ s.bindingsSessions = Array.isArray(payload.sessions) ? payload.sessions : []
655
+ }
656
+
657
+ // 拉取绑定视图并写 store。
658
+ //
659
+ // 失败必须留下痕迹(同 loadHistory):界面停在「加载中」与「确实没有绑定」
660
+ // 长得一样,而这一组的全部意义就是回答「我这个项目现在是谁」。
661
+ async loadBindings() {
662
+ if (this.disposed) return
663
+ try {
664
+ const payload = await this.fetchBindings()
665
+ this.store.update((s) => {
666
+ this.applyBindings(s, payload)
667
+ s.bindingsError = null
668
+ })
669
+ } catch (err) {
670
+ this.store.update((s) => {
671
+ s.bindingsError = messageOf(err)
672
+ })
673
+ }
674
+ }
675
+
676
+ // 新增或改绑一个目录。写成功的结果里带回完整视图,直接铺进 store,
677
+ // 省掉一次「写完立刻再拉一遍」的往返。
678
+ async saveBinding(dir, persona) {
679
+ const payload = await this.postJSON('/api/soul/bindings', { dir, persona })
680
+ if (!this.disposed) {
681
+ this.store.update((s) => {
682
+ this.applyBindings(s, payload)
683
+ s.bindingsError = null
684
+ })
685
+ }
686
+ return payload
687
+ }
688
+
689
+ async removeBinding(dir) {
690
+ const payload = await this.postJSON('/api/soul/bindings/delete', { dir })
691
+ if (!this.disposed) {
692
+ this.store.update((s) => {
693
+ this.applyBindings(s, payload)
694
+ s.bindingsError = null
695
+ })
696
+ }
697
+ return payload
698
+ }
699
+
524
700
  // 统一请求:body 为 null 时不携带请求体(GET 用)
525
701
  async postJSON(path, body, overrides = {}) {
526
702
  const url = new URL(path, hostBase())
@@ -605,6 +781,8 @@ window.__ModuleLoader__.load({
605
781
  '.soul-accordion-icon{flex:0 0 18px;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:1;text-align:center}',
606
782
  // 收起态只有触发器一个子元素 ⇒ 四角都要圆(展开态由上面的顶圆角规则负责)
607
783
  '.soul-accordion:not(.soul-accordion-open) .soul-accordion-trigger{border-radius:calc(var(--dsw-radius-xl) - .5px)}',
784
+ // 折叠标题旁的问号提示已取消(说明改放展开区内的 .soul-note):
785
+ // head 层随之删除 —— 触发器恢复成 section 的直接子元素,无需 flex 包裹。
608
786
  '.soul-accordion-body{padding:14px;border-top:.5px solid var(--dsw-alias-settings-card-stroke);background:transparent}',
609
787
  '.soul-field select:focus,.soul-field textarea:focus,.soul-field input[type=text]:focus{outline:none;border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 25%, transparent)}',
610
788
  '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:14px}',
@@ -643,6 +821,15 @@ window.__ModuleLoader__.load({
643
821
  '.soul-hint:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;border-radius:3px}',
644
822
  '.soul-hint:focus-visible .soul-hint-tip{opacity:1;visibility:visible}',
645
823
  '.soul-status{margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
824
+ // 说明块:一组「读到就够、不需要操作」的文字(预设收录范围 + 变更记录去哪了)。
825
+ // 与 .soul-status 同排印,但允许多行分行 —— 多条说明挤成一段会像一句话里
826
+ // 混了两件事。行距靠 line-height 自然撑开,不上边框(不是引用也不是分隔)。
827
+ '.soul-note{margin-top:10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
828
+ '.soul-note > div + div{margin-top:2px}',
829
+ // 一句话 + 一个问号:正文不再换行成段,问号挂在句子末尾(0.8.1 文案瘦身)。
830
+ // align-items:baseline 让问号与 12px 文字同基线 —— 用 center 会让它看起来浮起来。
831
+ '.soul-note-inline{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}',
832
+ '.soul-note-text{min-width:0}',
646
833
  '.soul-dirty{color:var(--dsw-alias-state-warn-primary)}',
647
834
  // —— 人设预设:竖版卡牌 + 堆叠轮播 ——
648
835
  // 方案 B:插画是卡面主视觉(方形满铺),名称与摘要压在图上由渐变承接,
@@ -869,6 +1056,120 @@ window.__ModuleLoader__.load({
869
1056
  '.soul-trail-preview{position:relative;margin-top:6px;padding:16px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}',
870
1057
  '.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
871
1058
  '.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
1059
+ // —— 变更记录(0.8.0)——
1060
+ // 顶部状态行 + 右侧「清空记录」:与 .soul-drift 同款(space-between),
1061
+ // 因为它也是「一句话 + 一个行动」的形态。
1062
+ '.soul-history-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:6px}',
1063
+ '.soul-history-count{margin-top:0}',
1064
+ // —— 插件详情页外壳(0.8.1 双入口):对齐宿主 detailSection 排版 ——
1065
+ // 宿主 .detailSection = flex column / gap 12px;.sectionHead = baseline 对齐;
1066
+ // .sectionTitle = 14px/500/20px;.sectionCount = 12px 次要色(fO69Vq_ 前缀)。
1067
+ '.soul-detail-history{display:flex;flex-direction:column;gap:12px}',
1068
+ '.soul-detail-history-head{display:flex;align-items:baseline;gap:10px}',
1069
+ '.soul-detail-history-title{margin:0;font-size:14px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-primary)}',
1070
+ '.soul-detail-history-count{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
1071
+ // 保留条数(0.8.1 从设置页迁来):一行「标签 + 下拉 + 提示」。
1072
+ // 下拉不撑满整行 —— 它是容量参数,不是表单主字段,铺满会显得比记录本身还重。
1073
+ '.soul-history-retention{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
1074
+ '.soul-history-retention label{margin:0;flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
1075
+ '.soul-history-retention select{flex:none;width:auto;min-width:112px;padding:4px 8px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px}',
1076
+ '.soul-history-retention select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
1077
+ '.soul-history-retention select:disabled{opacity:.6}',
1078
+ // 下拉右侧的问号提示沿用设置页的 SoulHint;这里只压掉它的上边距以对齐这一行
1079
+ '.soul-history-retention .soul-hint{margin-top:0}',
1080
+ // —— 设置页指引(0.8.1 双入口)——
1081
+ // 列表整体迁去插件详情页后,设置页不再有指引行;去向说明并入「人设预设」
1082
+ // 展开区的 .soul-note(见下方 .soul-note 注释),不再单独占一行。
1083
+ // 危险色描边文字钮(规格抄 .soul-persona-danger):清空记录不可逆,
1084
+ // 是这一组唯一的破坏性动作。撤销按钮不用危险色 —— 它可以再撤销一次改回来。
1085
+ '.soul-history-clear{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;height:24px;padding:0 10px;border:.5px solid var(--dsw-alias-state-error-primary);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}',
1086
+ '.soul-history-clear:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(236,19,19,.05))}',
1087
+ '.soul-history-clear:focus-visible{outline:2px solid var(--dsw-alias-state-error-primary);outline-offset:1px}',
1088
+ // armed:实心危险色 + 文案变成「确认清空?」(样式抄 .soul-persona-danger-armed)
1089
+ '.soul-history-clear-armed{background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-label-primary-foreground)}',
1090
+ '.soul-history-warning{margin-top:6px}',
1091
+ '.soul-history-list{display:flex;flex-direction:column;gap:6px;margin:10px 0 0 0;padding:0;list-style:none}',
1092
+ '.soul-history-item{border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-module-platform);overflow:hidden}',
1093
+ // 头部是「主体按钮 + 操作按钮」两段:主体撑满左侧,操作固定在右,
1094
+ // 因此主体按钮必须 min-width:0 才能让里面的 ellipsis 生效。
1095
+ '.soul-history-item-head{display:flex;align-items:center;gap:8px;padding-right:8px}',
1096
+ '.soul-history-item-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:8px 10px;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;text-align:left;cursor:pointer}',
1097
+ '.soul-history-item-main:hover{background:var(--dsw-alias-interactive-bg-hover)}',
1098
+ '.soul-history-item-main:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
1099
+ // 时间与来源不参与收缩(它们短且固定),字段名列表吃掉剩余宽度并省略。
1100
+ '.soul-history-time{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}',
1101
+ '.soul-history-source{flex:none;padding:0 6px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xs);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;white-space:nowrap}',
1102
+ '.soul-history-fields{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
1103
+ '.soul-history-item-actions{display:flex;align-items:center;gap:6px;flex:none}',
1104
+ '.soul-history-action{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;height:24px;padding:0 8px;border:.5px solid var(--dsw-alias-brand-primary);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}',
1105
+ '.soul-history-action:hover{background:var(--dsw-alias-interactive-bg-hover)}',
1106
+ '.soul-history-action:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
1107
+ '.soul-history-action-armed{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}',
1108
+ '.soul-history-detail{padding:0 10px 10px 10px;border-top:.5px solid var(--dsw-alias-settings-card-stroke)}',
1109
+ '.soul-history-diff{display:flex;flex-direction:column;gap:10px;padding-top:8px}',
1110
+ // 「展开即全文」:每行竖排 —— 字段名、原值块、新值块依次铺开。
1111
+ // 曾经是 88px/1fr/14px/1fr 的四列网格 + 单行省略,但自定义指令几百字时
1112
+ // 一行省略号等于没给;竖排让长文本换行铺开,短字段也不吃亏。
1113
+ '.soul-history-diff-row{display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px}',
1114
+ '.soul-history-diff-field{color:var(--dsw-alias-label-tertiary);word-break:break-word}',
1115
+ // pre-wrap:介绍 / 自定义指令里的换行是内容的一部分,压成一行会看不出改了什么。
1116
+ '.soul-history-diff-cell{display:flex;flex-direction:column;gap:2px}',
1117
+ '.soul-history-diff-tag{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
1118
+ '.soul-history-diff-value{min-width:0;white-space:pre-wrap;word-break:break-word}',
1119
+ // 原值块垫底色区分「这是被替换掉的」,新值保持正文色;省略号阶段二值同权重,全文阶段必须能一眼分清。
1120
+ '.soul-history-diff-cell:first-of-type .soul-history-diff-value{color:var(--dsw-alias-label-tertiary)}',
1121
+ '.soul-history-diff-value{color:var(--dsw-alias-label-primary)}',
1122
+ '.soul-history-diff-persona{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
1123
+ '.soul-history-more{align-self:flex-start;margin-top:8px;padding:0;border:0;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
1124
+ '.soul-history-more:hover{text-decoration:underline}',
1125
+ '.soul-history-more:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
1126
+ // —— 设置页「工作区绑定」分组(0.8.1-d)——
1127
+ // 外层容器只负责块间距:三段是 .soul-field,彼此之间要留出与其它分组一致的空。
1128
+ '.soul-bindings{display:flex;flex-direction:column;gap:14px}',
1129
+ // 尺寸全部抄本文件既有的宿主对齐规格:输入框复用 .soul-field 的控件规则,
1130
+ // 小按钮复用 .soul-history-action(24px 高 / 描边 / brand 文字),
1131
+ // 危险态复用 .soul-history-action-armed 的语义(实心危险色 + 换文案)。
1132
+ // 这里只补「绑定」独有的排版:路径与元信息的折叠、列表的树形前缀、当前目录的高亮。
1133
+ // 当前会话目录卡:一行等宽路径 + 两行元信息(生效人设 / 来源),
1134
+ // 路径靠 title 给全文、靠 shortenPath 截中段 —— 不换行,避免长路径把卡片撑高
1135
+ '.soul-bindings-current{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1)}',
1136
+ // 路径用等宽字体:宿主主题**没有** --dsw-font-mono 这个 token(实测校对过),
1137
+ // 引一个不存在的 var() 只会静默回退,所以这里直接给系统等宽字体栈。
1138
+ '.soul-bindings-path{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
1139
+ '.soul-bindings-meta{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
1140
+ // 输入框 + 「选择目录…」同一行:按钮不缩放(flex:none),输入框吃满剩余宽度
1141
+ '.soul-bindings-new{display:flex;align-items:center;gap:8px;margin-bottom:8px}',
1142
+ '.soul-bindings-new:last-child{margin-bottom:0}',
1143
+ '.soul-bindings-new label{margin:0;flex:none}',
1144
+ '.soul-bindings-pick,.soul-bindings-bind{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-brand-primary);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
1145
+ '.soul-bindings-pick:hover,.soul-bindings-bind:hover{background:var(--dsw-alias-interactive-bg-hover)}',
1146
+ '.soul-bindings-pick:focus-visible,.soul-bindings-bind:focus-visible,.soul-bindings-action:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
1147
+ // 人设下拉不许被压到看不见:flex:none + 固定最小宽度
1148
+ '.soul-bindings-new select{flex:none;width:auto;min-width:120px}',
1149
+ // 降级提示:与 .soul-status 同色同字号,贴在选择器原位(说明为什么按钮不见了)
1150
+ '.soul-bindings-hint{margin:-2px 0 8px 0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
1151
+ // 列表:与 .soul-history-list 同规格(紧凑、无项目符号)
1152
+ '.soul-bindings-rows{display:flex;flex-direction:column;gap:6px;margin:10px 0 0 0;padding:0;list-style:none}',
1153
+ '.soul-bindings-row{display:flex;align-items:center;gap:6px;min-height:32px;padding:4px 8px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1)}',
1154
+ // 高亮命中当前会话的那一条:左侧一道 brand 竖条,比整块上色更安静
1155
+ '.soul-bindings-row[data-current="true"]{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary)}',
1156
+ '.soul-bindings-tree{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}',
1157
+ // 目录名是这一行的主体:吃掉剩余宽度,过长则省略(全文在 title)
1158
+ '.soul-bindings-dir{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
1159
+ '.soul-bindings-here{flex:none;padding:0 6px;border-radius:var(--dsw-radius-sm);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;line-height:18px}',
1160
+ '.soul-bindings-bad{flex:none;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}',
1161
+ '.soul-bindings-select{flex:none;width:auto;min-width:96px;max-width:160px}',
1162
+ '.soul-bindings-action{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;height:24px;padding:0 8px;border:.5px solid var(--dsw-alias-brand-primary);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
1163
+ '.soul-bindings-action:hover{background:var(--dsw-alias-interactive-bg-hover)}',
1164
+ '.soul-bindings-action-armed{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}',
1165
+ // 未绑定回落行:虚线框=「这是规则、不是一条记录」
1166
+ '.soul-bindings-fallback{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border:.5px dashed var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm)}',
1167
+ '.soul-bindings-fallback-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
1168
+ // 「看到的不是全部」不是失败:用次要色,与红字错误(.soul-error)区分。
1169
+ // 宿主主题**没有**黄色 warning token(实测:--dsw-alias-label-warning /
1170
+ // --dsw-alias-state-warning-primary 都不存在),自己硬编一个黄色又会跟主题脱节,
1171
+ // 所以按「不重要但要知道」处理,与同组其它提示同色。
1172
+ '.soul-bindings-warning{color:var(--dsw-alias-label-secondary)}',
872
1173
  // 开了「减少动态效果」时:光轨层与卡片停动画。
873
1174
  // 结果提示(绿字/红字)不需要在这里照顾 —— 它们是静态文字、没有动画。
874
1175
  '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}.soul-persona-card{animation:none!important;transition:none}}'
@@ -965,7 +1266,13 @@ window.__ModuleLoader__.load({
965
1266
  'personas.builtin': '内置',
966
1267
  'personas.drift': '当前配置与任何预设都不一致。',
967
1268
  'personas.driftAction': '存为新预设',
968
- 'personas.hint': '预设只保存 Agent 的人设(风格 / 特质 / 回复长度 / 输出语言 / 自定义指令),不含「关于你」;内置预设随插件提供,不可删除',
1269
+ // 一句话就够了:用户的真实疑问只有「存了这个会不会连我的昵称也一起换掉」。
1270
+ // 「不可删除」不再写进正文 —— 每张内置卡上都挂着「内置」徽标、且根本没有删除按钮,
1271
+ // 说第二遍只是噪音。
1272
+ 'personas.hint': '预设只含 Agent 的人格;你的昵称与自我介绍不随预设切换。',
1273
+ // hover 里的全文:原来那段正文原样保留在这里,需要细节的人仍能读到,
1274
+ // 不需要的人不必被它占两行。
1275
+ 'personas.hintDetail': '预设有风格、特质(标题和列表 / 表情符号 / 表格)、回复长度、输出语言、自定义指令;不含「关于你」里的昵称、职业与自我介绍。内置预设随插件提供,不可删除。',
969
1276
  'style.professional': '专业严谨',
970
1277
  'style.casual': '轻松自然',
971
1278
  'style.humorous': '幽默风趣',
@@ -994,9 +1301,114 @@ window.__ModuleLoader__.load({
994
1301
  'meta.tables.less': '少用表格',
995
1302
  'meta.language.zh': '中文',
996
1303
  'meta.language.en': '英文',
1304
+ // —— 工作区绑定(0.8.1)——
1305
+ 'group.bindings': '工作区绑定',
1306
+ 'bindings.hint': '绑定记住的是目录而不是会话:在绑定的目录(含子目录)里开的会话自动用这个人设。保存后立刻对活动会话生效,其他目录不受影响。',
1307
+ // 当前会话目录卡
1308
+ 'bindings.currentTitle': '当前会话目录',
1309
+ 'bindings.noSession': '当前没有活动会话,所以没有「现在用什么」可显示。',
1310
+ 'bindings.noCwd': '这个会话没有工作目录,任何绑定都不会命中 —— 它会用全局配置。',
1311
+ 'bindings.effective': '生效人设',
1312
+ 'bindings.source': '来源',
1313
+ 'bindings.sourceGlobal': '全局配置(没有绑定的目录都归它管)',
1314
+ 'bindings.sourceInherited': '{dir}(上级目录继承)',
1315
+ 'bindings.choose': '选择目录…',
1316
+ 'bindings.manualLabel': '目录路径',
1317
+ 'bindings.manualPlaceholder': '粘贴一个绝对路径,例如 D:\\项目A',
1318
+ 'bindings.bindDir': '绑定',
1319
+ // 结果提示:就地在分组内显示绿字/红字(与变更记录同款,不用 toast)
1320
+ 'bindings.needDir': '先填一个目录,或者点「选择目录…」。',
1321
+ 'bindings.bound': '已绑定:{dir} → {name}',
1322
+ 'bindings.unbound': '已解绑:{dir}',
1323
+ 'bindings.unbindMissing': '这条绑定已经不在列表里了(可能是在别处删掉的),刷新一下即可。',
1324
+ 'bindings.pickerUnavailable': '当前连接方式无法调起系统目录选择器,请手动粘贴路径。',
1325
+ 'bindings.moreSessions': '另有 {count} 个活动会话',
1326
+ // 绑定列表
1327
+ 'bindings.listTitle': '已绑定的目录',
1328
+ 'bindings.loading': '加载中...',
1329
+ 'bindings.loadFailed': '无法读取工作区绑定:{reason}',
1330
+ 'bindings.empty': '还没有绑定任何目录。',
1331
+ 'bindings.count': '共 {count} 条 · 最多 {max} 条',
1332
+ 'bindings.here': '当前目录',
1333
+ 'bindings.persona': '使用的人设',
1334
+ 'bindings.unbind': '解绑',
1335
+ 'bindings.unbindArmed': '确认解绑?',
1336
+ 'bindings.invalid': '人设「{name}」已不存在',
1337
+ 'bindings.invalidHint': '这条绑定不会生效,改绑或解绑它。',
1338
+ 'bindings.dropped': '有 {count} 行无效记录已被忽略。',
1339
+ 'bindings.warning': '绑定表可能不完整:{reason}',
1340
+ 'bindings.maxPath': '路径上限 {max} 字符。',
1341
+ 'bindings.fallback': '未绑定的目录使用全局配置',
1342
+ 'bindings.change': '改',
997
1343
  'group.tool': 'Agent 工具',
998
1344
  'field.toolConfirm': '人设变更需确认',
999
1345
  'hint.toolConfirm': '开启后,Agent 改人设会先在输入框处弹出审批面板(Enter 允许 / Esc 拒绝)。若会话审批通道不可用(策略为 never),则退化为待确认提议,需用 /soul confirm 处理 —— 此时建议关闭本开关,否则每次改动都要手输命令。',
1346
+ // —— 变更记录(0.8.0)——
1347
+ 'group.history': '变更记录',
1348
+ 'history.empty': '还没有任何变更记录。',
1349
+ 'history.loading': '加载中...',
1350
+ 'history.loadFailed': '无法读取变更记录:{reason}',
1351
+ 'history.summary': '最近 {count} 条 · 最多保留 {max} 条',
1352
+ 'history.more': '还有 {count} 条',
1353
+ 'history.clear': '清空记录',
1354
+ 'history.clearArmed': '确认清空?',
1355
+ 'history.clearDone': '已清空 {count} 条变更记录。',
1356
+ // 按钮用「撤销」而不是「恢复」:点一条记录的结果是**把这一次改动撤掉**
1357
+ // (连同它之后的),不是「切回那天的状态」。
1358
+ 'history.restore': '撤销这一次',
1359
+ 'history.restoreArmed': '撤销?',
1360
+ 'history.restoreDone': '已撤销 {count} 次改动。',
1361
+ 'history.restoreUnchanged': '在撤销之前,这些改动就已经被后续改动改回去了。',
1362
+ 'history.restorePreset': '恢复此预设',
1363
+ 'history.restorePresetDone': '已恢复预设「{name}」。',
1364
+ 'history.restorePresetSkipped': '未恢复「{name}」:{reason}',
1365
+ 'history.skip.exists': '当前已有同名的预设',
1366
+ 'history.skip.builtin': '这是内置预设',
1367
+ 'history.skip.invalid-name': '名字不合法',
1368
+ 'history.restoreSkippedNote': '已恢复 {count} 个,另有 {skipped} 个未恢复。',
1369
+ 'history.diverged': '记录与当前配置对不上,无法撤销。可能有人手工改过配置文件。',
1370
+ 'history.warning': '记录可能不完整:{reason}',
1371
+ 'history.footer': '撤销人设配置,不影响人设预设库。',
1372
+ 'history.fields': '涉及 {fields}',
1373
+ 'history.field.from': '原值',
1374
+ 'history.field.to': '新值',
1375
+ 'history.personasAdded': '新增预设 {names}',
1376
+ 'history.personasRemoved': '删除预设 {names}',
1377
+ 'history.personasUpdated': '覆盖预设 {names}',
1378
+ // —— label:条目的人类可读上下文(0.8.0 打磨)——
1379
+ // 折叠行里「应用预设 · 自定义指令」答不了「应用的是什么」;有 label 时拼在来源后面。
1380
+ 'history.labelSuffix': ':{label}',
1381
+ 'history.source.web': '网页保存',
1382
+ 'history.source.command': '斜杠命令',
1383
+ 'history.source.tool': 'Agent 工具',
1384
+ 'history.source.service': '插件调用',
1385
+ 'history.source.persona': '应用预设',
1386
+ 'history.source.rollback': '撤销',
1387
+ 'history.source.restorePersona': '恢复预设',
1388
+ // —— 变更记录:列表渲染补充(0.8.0-d)——
1389
+ // 服务端没带回容量上限时的退路:宁可不提「最多保留 N 条」,也不编一个数字出来。
1390
+ 'history.summaryCount': '最近 {count} 条',
1391
+ // 双入口(0.8.1):主入口在插件详情页,设置页不再留独立指引行(突兀且占地);
1392
+ // 变更记录的主入口在插件详情页。这里不再罗列「撤销、恢复预设、清空都在那里」
1393
+ // —— 那是三个按钮的名字,点进去自然看得到;罗列只会把一行提示撑成一段话。
1394
+ 'history.entryHint': '改动都能撤销',
1395
+ // hover 全文:具体的「在哪儿、能做什么」。正文只留一句判断,细节按需展开。
1396
+ 'history.entryHintMore': '变更记录在插件详情页(插件管理 → 已安装插件 → dsh-soul):撤销某次改动及其之后的全部改动、恢复被删的预设、清空记录,都在那里。',
1397
+ // —— 变更记录保留条数(0.8.0 打磨)——
1398
+ 'history.maxEntries': '保留条数',
1399
+ 'history.maxEntriesHint': '变更记录最多保留多少条。超出后自动丢弃最旧的记录;条数越多,可撤销的范围越大。',
1400
+ 'history.maxEntriesDefault': '{count} 条(默认)',
1401
+ 'history.maxEntriesOption': '{count} 条',
1402
+ 'history.maxEntriesSaved': '保留条数已改为 {count} 条。',
1403
+ 'history.timeJustNow': '刚刚',
1404
+ 'history.timeMinutes': '{count} 分钟前',
1405
+ 'history.timeHours': '{count} 小时前',
1406
+ 'history.timeDays': '{count} 天前',
1407
+ // 空值必须显式画出来:留空会被读成「渲染坏了」,而「这次把它清空了」是一条事实
1408
+ 'history.valueEmpty': '(空)',
1409
+ // 服务端不记空条目,所以这一句正常情况下到不了屏幕;但记录文件是可以被手工
1410
+ // 改坏的,真遇到时宁可写「无字段变化」,也不要显示成裸键名 history.emptyFields。
1411
+ 'history.emptyFields': '无字段变化',
1000
1412
  'button.save': '保存设置',
1001
1413
  'button.saving': '保存中...',
1002
1414
  'button.cancel': '取消',
@@ -1124,7 +1536,8 @@ window.__ModuleLoader__.load({
1124
1536
  'personas.builtin': 'Built-in',
1125
1537
  'personas.drift': 'The current config matches no preset.',
1126
1538
  'personas.driftAction': 'Save as new preset',
1127
- 'personas.hint': 'A persona saves the agent personality only (style / traits / reply length / reply language / custom instructions), never your own identity fields; built-ins ship with the plugin and cannot be deleted',
1539
+ 'personas.hint': 'Personas hold only the agent personality; your nickname and bio never switch with a persona.',
1540
+ 'personas.hintDetail': 'A persona covers style, traits (headings & lists / emoji / tables), reply length, reply language and custom instructions — never the nickname, occupation or bio under "About you". Built-in personas ship with the plugin and cannot be deleted.',
1128
1541
  'style.professional': 'Professional',
1129
1542
  'style.casual': 'Casual',
1130
1543
  'style.humorous': 'Humorous',
@@ -1153,9 +1566,108 @@ window.__ModuleLoader__.load({
1153
1566
  'meta.tables.less': 'Fewer tables',
1154
1567
  'meta.language.zh': 'Chinese',
1155
1568
  'meta.language.en': 'English',
1569
+ // —— Workspace bindings (0.8.1) ——
1570
+ 'group.bindings': 'Workspace bindings',
1571
+ 'bindings.hint': 'A binding remembers a directory, not a session: sessions opened in that directory (or below it) use the bound persona. Saving takes effect for active sessions right away; other directories are untouched.',
1572
+ 'bindings.currentTitle': 'Current session directory',
1573
+ 'bindings.noSession': 'No active session, so there is no "in effect right now" to show.',
1574
+ 'bindings.noCwd': 'This session has no working directory, so no binding can match it — it uses the global config.',
1575
+ 'bindings.effective': 'Persona in effect',
1576
+ 'bindings.source': 'Source',
1577
+ 'bindings.sourceGlobal': 'Global config (everything unbound falls back to it)',
1578
+ 'bindings.sourceInherited': '{dir} (inherited from a parent directory)',
1579
+ 'bindings.choose': 'Choose directory…',
1580
+ 'bindings.manualLabel': 'Directory path',
1581
+ 'bindings.manualPlaceholder': 'Paste an absolute path, e.g. D:\\project-a',
1582
+ 'bindings.bindDir': 'Bind',
1583
+ 'bindings.needDir': 'Enter a directory first, or use "Choose directory…".',
1584
+ 'bindings.bound': 'Bound: {dir} → {name}',
1585
+ 'bindings.unbound': 'Unbound: {dir}',
1586
+ 'bindings.unbindMissing': 'This binding is no longer in the list (it was probably removed elsewhere); reload to refresh.',
1587
+ 'bindings.pickerUnavailable': 'This connection cannot open the system directory picker; paste the path instead.',
1588
+ 'bindings.moreSessions': '{count} more active session(s)',
1589
+ 'bindings.listTitle': 'Bound directories',
1590
+ 'bindings.loading': 'Loading...',
1591
+ 'bindings.loadFailed': 'Could not read the workspace bindings: {reason}',
1592
+ 'bindings.empty': 'No directory is bound yet.',
1593
+ 'bindings.count': '{count} total · up to {max}',
1594
+ 'bindings.here': 'This directory',
1595
+ 'bindings.persona': 'Persona',
1596
+ 'bindings.unbind': 'Unbind',
1597
+ 'bindings.unbindArmed': 'Unbind it?',
1598
+ 'bindings.invalid': 'Persona "{name}" no longer exists',
1599
+ 'bindings.invalidHint': 'This binding will not take effect; rebind or remove it.',
1600
+ 'bindings.dropped': '{count} invalid line(s) were ignored.',
1601
+ 'bindings.warning': 'The binding table may be incomplete: {reason}',
1602
+ 'bindings.maxPath': 'Path limit {max} characters.',
1603
+ 'bindings.fallback': 'Unbound directories use the global config',
1604
+ 'bindings.change': 'Change',
1156
1605
  'group.tool': 'Agent tools',
1157
1606
  'field.toolConfirm': 'Require confirmation for persona changes',
1158
1607
  'hint.toolConfirm': 'When on, persona changes by the agent first raise an approval panel above the input box (Enter = allow, Esc = reject). If the session approval channel is unavailable (policy = never), it falls back to a pending proposal applied only via /soul confirm. Under full permissions the panel never appears, so turning this switch off is recommended.',
1608
+ // —— Change history (0.8.0) ——
1609
+ 'group.history': 'Change history',
1610
+ 'history.empty': 'No changes recorded yet.',
1611
+ 'history.loading': 'Loading...',
1612
+ 'history.loadFailed': 'Could not read the change history: {reason}',
1613
+ 'history.summary': 'Last {count} · keeps up to {max}',
1614
+ 'history.more': '{count} more',
1615
+ 'history.clear': 'Clear history',
1616
+ 'history.clearArmed': 'Clear it?',
1617
+ 'history.clearDone': 'Cleared {count} change records.',
1618
+ // "Undo" rather than "restore": clicking a record reverts **that change**
1619
+ // (and everything after it), it does not jump back to that day's state.
1620
+ 'history.restore': 'Undo this change',
1621
+ 'history.restoreArmed': 'Undo?',
1622
+ 'history.restoreDone': 'Undid {count} change(s).',
1623
+ 'history.restoreUnchanged': 'Those changes had already been reverted by later edits.',
1624
+ 'history.restorePreset': 'Restore this preset',
1625
+ 'history.restorePresetDone': 'Restored preset "{name}".',
1626
+ 'history.restorePresetSkipped': 'Did not restore "{name}": {reason}',
1627
+ 'history.skip.exists': 'a preset with that name already exists',
1628
+ 'history.skip.builtin': 'it is a built-in preset',
1629
+ 'history.skip.invalid-name': 'the name is not valid',
1630
+ 'history.restoreSkippedNote': 'Restored {count}; {skipped} were not restored.',
1631
+ 'history.diverged': 'The history does not match the current config, so it cannot be undone. The config file may have been edited by hand.',
1632
+ 'history.warning': 'The history may be incomplete: {reason}',
1633
+ 'history.footer': 'Undoes persona settings only; your preset library is untouched.',
1634
+ 'history.fields': 'Touches {fields}',
1635
+ 'history.field.from': 'was',
1636
+ 'history.field.to': 'now',
1637
+ 'history.personasAdded': 'Added preset {names}',
1638
+ 'history.personasRemoved': 'Deleted preset {names}',
1639
+ 'history.personasUpdated': 'Overwrote preset {names}',
1640
+ 'history.labelSuffix': ': {label}',
1641
+ 'history.source.web': 'Web save',
1642
+ 'history.source.command': 'Slash command',
1643
+ 'history.source.tool': 'Agent tool',
1644
+ 'history.source.service': 'Plugin call',
1645
+ 'history.source.persona': 'Preset applied',
1646
+ 'history.source.rollback': 'Undo',
1647
+ 'history.source.restorePersona': 'Preset restored',
1648
+ // —— Change history: list rendering (0.8.0-d) ——
1649
+ // Fallback for when the server did not report a capacity: better to omit
1650
+ // "keeps up to N" than to invent a number that reads like a promise.
1651
+ 'history.summaryCount': 'Last {count}',
1652
+ // Dual entry (0.8.1): the full view lives on the plugin detail page; the
1653
+ // settings group becomes a pointer line so a low-frequency feature stops
1654
+ // occupying the settings page permanently.
1655
+ 'history.entryHint': 'Every change can be undone',
1656
+ 'history.entryHintMore': 'Change history lives on the plugin details page (Plugin manager → Installed plugins → dsh-soul): undo a change together with everything after it, restore a deleted preset, and clear the log.',
1657
+ // —— Change history retention (0.8.0 polish) ——
1658
+ 'history.maxEntries': 'Entries kept',
1659
+ 'history.maxEntriesHint': 'How many change-history entries to keep. Older entries are dropped once the limit is reached; a larger number means a longer undo window.',
1660
+ 'history.maxEntriesDefault': '{count} entries (default)',
1661
+ 'history.maxEntriesOption': '{count} entries',
1662
+ 'history.maxEntriesSaved': 'Entries kept is now {count}.',
1663
+ 'history.timeJustNow': 'just now',
1664
+ 'history.timeMinutes': '{count} min ago',
1665
+ 'history.timeHours': '{count} h ago',
1666
+ 'history.timeDays': '{count} d ago',
1667
+ // An empty value must be drawn explicitly: leaving it blank reads as "broken",
1668
+ // while "it was cleared" is itself a fact worth showing.
1669
+ 'history.valueEmpty': '(empty)',
1670
+ 'history.emptyFields': 'No field changes',
1159
1671
  'button.save': 'Save Settings',
1160
1672
  'button.saving': 'Saving...',
1161
1673
  'button.cancel': 'Cancel',
@@ -1264,14 +1776,100 @@ window.__ModuleLoader__.load({
1264
1776
  { value: 'en', label: 'English' }
1265
1777
  ]
1266
1778
 
1267
- // 表单字段与 store 字段的一一对应(dirty 检测与保存载荷共用)
1268
- const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
1779
+ // 设置页表单驱动的字段(dirty 检测与保存载荷共用)。
1780
+ const FORM_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
1781
+
1782
+ // 由**别的界面**就地管理、因而不在这张表单里的配置字段:
1783
+ // `historyMaxEntries` 属于插件详情页的变更记录区块(改完即时落盘,不经保存按钮)。
1784
+ const EXTERNALLY_MANAGED_FIELDS = ['historyMaxEntries']
1785
+
1786
+ // 配置字段全集(宿主 DEFAULT_CONFIG 的对账基准)。
1787
+ // 顺序与 DEFAULT_CONFIG 一致,便于人眼对账;重复字段会让 dirty 判定重复计算。
1788
+ // **表单只认 FORM_FIELD_KEYS** —— 拿全集去判 dirty 会让表单对着一个它并不持有的
1789
+ // 字段反复比较,语义混乱(当前恰好因为两边都是 undefined 而等价,但那是巧合)。
1790
+ const FIELD_KEYS = [...FORM_FIELD_KEYS, ...EXTERNALLY_MANAGED_FIELDS]
1791
+
1792
+ // 变更记录保留条数的可选值。**只放整数档位,不做自由输入**:
1793
+ // 这是个一次设定、此后不再碰的容量参数,自由输入只会带来
1794
+ // 「输 30 被夹到 50」这种需要额外解释的状态。默认档(200)单独标注出来,
1795
+ // 用户想回到出厂设置时一眼能找到。
1796
+ // (0.8.1 起这个下拉在插件详情页的变更记录区块内,不在设置页 —— 见下方
1797
+ // `HISTORY_MAX_ENTRIES_OPTIONS` 所在位置的说明。)
1269
1798
 
1270
1799
  // 其中真正参与 compilePrompt 的字段子集。
1271
1800
  // 提示词预览只依赖它们:改光轨颜色 / 粗细、切换 set_persona 确认模式都不会改变
1272
1801
  // 编译结果,不该因此重新请求,也不该让预览被标成「未保存」。
1273
1802
  const PROMPT_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
1274
1803
 
1804
+ // —— 变更记录(0.8.0)——
1805
+ //
1806
+ // 审计字段的文案键。**必须覆盖 lib/history.mjs 的 AUDIT_FIELDS**(= DEFAULT_CONFIG 全键):
1807
+ // 漏掉一个,时间线里就会露出一串裸键名(`trailWidth`)。有断言盯着这张表。
1808
+ // 复用设置页既有文案而不是另写一份:同一样东西在两处叫不同名字,用户会以为不是一回事。
1809
+ const HISTORY_FIELD_LABELS = {
1810
+ enabled: 'toggle.enabled',
1811
+ nickname: 'field.nickname',
1812
+ occupation: 'field.occupation',
1813
+ bio: 'field.bio',
1814
+ style: 'field.style',
1815
+ headingLists: 'field.headingLists',
1816
+ emoji: 'field.emoji',
1817
+ tables: 'field.tables',
1818
+ replyLength: 'field.replyLength',
1819
+ language: 'field.language',
1820
+ customInstructions: 'field.instructions',
1821
+ requireToolConfirmation: 'field.toolConfirm',
1822
+ trailEnabled: 'trail.enable',
1823
+ trailColor: 'trail.color',
1824
+ trailSpeed: 'trail.speed',
1825
+ trailWidth: 'trail.width',
1826
+ // 保留条数本身也是被审计的字段:改它同样会留一条记录(否则用户改了容量,
1827
+ // 记录里查不到是谁改的),所以它必须有自己的标签。
1828
+ historyMaxEntries: 'history.maxEntries'
1829
+ }
1830
+
1831
+ // 与 lib/history.mjs 的 HISTORY_SOURCES 对齐;认不出的来源原样显示。
1832
+ const HISTORY_SOURCES = ['web', 'command', 'tool', 'service', 'persona', 'rollback', 'restorePersona']
1833
+
1834
+ // 一次最多铺开多少条。记录上限是 200 条,全渲染会让这一组比整个设置页还长;
1835
+ // 超出的折成一行「还有 N 条」,想看完的人再点一次。
1836
+ const HISTORY_RENDER_LIMIT = 20
1837
+
1838
+ // —— 变更记录保留条数(0.8.1 从设置页迁到插件详情页)——
1839
+ //
1840
+ // 它描述的是「这个区块自己保留多少条」,放在它**自己旁边**才是对话该发生的位置:
1841
+ // 用户在详情页看到「最近 37 条 · 最多保留 200 条」,想改这个 200 时就地能改,
1842
+ // 而不必先去设置页的「人设预设」组里找 —— 那个位置解释不清(它既不是预设,
1843
+ // 也只是「变更记录」这个分组的容量,而设置页早已没有变更记录分组了)。
1844
+ //
1845
+ // 档位由服务端 HISTORY_MAX_ENTRIES 与 clampHistoryMaxEntries 兜底;客户端这组
1846
+ // 常量只负责「给哪些选项」。上限 500 因为条目只存变化字段、远在 512 KB 之内;
1847
+ // 下限 50 因为再小则「上一轮就没了」。
1848
+ const HISTORY_MAX_ENTRIES_OPTIONS = [50, 100, 200, 500]
1849
+ const HISTORY_MAX_ENTRIES_DEFAULT = 200
1850
+
1851
+ // 枚举字段 → 既有文案键的映射表(模块级:SoulSettings 与 SoulHistorySection 共用)。
1852
+ // 枚举值复用设置页的既有文案键(`style.*` / `trait.*` / `trail.speed.*` …),
1853
+ // 布尔用「已开启 / 未开启」,其余原样显示。
1854
+ const HISTORY_ENUM_KEYS = {
1855
+ style: (value) => `style.${value}`,
1856
+ headingLists: (value) => `trait.headingLists.${value}`,
1857
+ emoji: (value) => `trait.emoji.${value}`,
1858
+ tables: (value) => `trait.tables.${value}`,
1859
+ replyLength: (value) => `trait.replyLength.${value}`,
1860
+ trailSpeed: (value) => `trail.speed.${value}`,
1861
+ trailWidth: (value) => `trail.width.${value}`
1862
+ }
1863
+ const HISTORY_ENUM_VALUES = {
1864
+ style: STYLE_VALUES,
1865
+ headingLists: TRAIT_VALUES,
1866
+ emoji: TRAIT_VALUES,
1867
+ tables: TRAIT_VALUES,
1868
+ replyLength: REPLY_LENGTH_VALUES,
1869
+ trailSpeed: TRAIL_SPEED_VALUES,
1870
+ trailWidth: TRAIL_WIDTH_VALUES
1871
+ }
1872
+
1275
1873
  // 提示词小图标:hover / 键盘聚焦都展示说明文字。
1276
1874
  // 此前只有 :hover 可达,且提示节点用 visibility:hidden —— 那会把它移出无障碍树,
1277
1875
  // 键盘与读屏用户拿不到字段说明。这里补 tabIndex 让它可以聚焦,并在 :focus-visible
@@ -1298,6 +1896,11 @@ window.__ModuleLoader__.load({
1298
1896
  }
1299
1897
 
1300
1898
  // 可折叠设置面板:同一时间只展开一个区域,并持久化用户最后的选择。
1899
+ //
1900
+ // 曾经这里有个 `hint` prop,把问号提示渲染在折叠标题旁的 head 里。用户反馈:
1901
+ // 折叠态挂问号等于把说明放在「还没展开就能看到」的位置,而它解释的恰恰是展开后
1902
+ // 的东西。现在说明一律写进 body 里(能直接读的文字优先于 hover),
1903
+ // head 层随之取消 —— 触发器恢复成 section 的直接子元素。
1301
1904
  function SoulAccordion(props) {
1302
1905
  const { id, title, summary, open, onToggle, children } = props
1303
1906
  return e('section', { className: `soul-accordion${open ? ' soul-accordion-open' : ''}` },
@@ -1316,6 +1919,377 @@ window.__ModuleLoader__.load({
1316
1919
  )
1317
1920
  }
1318
1921
 
1922
+ // —— 变更记录(0.8.0 → 0.8.1 双入口改造)——
1923
+ //
1924
+ // 0.8.1 起主入口是**插件详情页**(plugins.detail.section 槽位):
1925
+ // 变更记录是低频的回顾性信息,长期占着设置页主区不值得;详情页地方大,
1926
+ // 可以按宿主 detailSection 的排版充分展示。设置页只留一行指引(见 SoulSettings)。
1927
+ //
1928
+ // 组件收 { useSoulController, controller, t, subject }:subject 由宿主传入
1929
+ //(bundle / item / row 三种形态)—— 只有 dsh-soul 自己的详情页才渲染,
1930
+ // 其他插件的详情页必须渲染 null(槽位契约原话:"renders null for a subject
1931
+ // it has nothing for")。挂载时才拉取:比设置页的「展开才拉」更懒。
1932
+ function SoulHistorySection(props) {
1933
+ const { useSoulController, controller } = props
1934
+ const t = typeof props.t === 'function' ? props.t : FALLBACK_T
1935
+ const subject = props.subject || null
1936
+ // 只服务自己的详情页。bundle 形态才有 pkg.name(item/row 形态没有,
1937
+ // 一律不渲染 —— 它们是另一个层级的页面)。
1938
+ if (!subject || subject.kind !== 'bundle' || !subject.pkg || subject.pkg.name !== 'dsh-soul') return null
1939
+
1940
+ const state = useSoulController((state) => state)
1941
+ const { history, historyWarning, historyMax, historyError, historyMaxEntries } = state
1942
+
1943
+ // 结果提示:与设置页同款双轨 —— 成功绿字(role=status)、失败红字(role=alert),
1944
+ // 失败压过成功。独立于 pendingUndo/pendingClear(那两个有 5s 超时)。
1945
+ const [notice, setNotice] = React.useState(null)
1946
+ const [errorNotice, setErrorNotice] = React.useState(null)
1947
+
1948
+ // —— 保留条数(0.8.1 从设置页迁来)——
1949
+ //
1950
+ // 它是**配置字段**(落 soul-config.json、进审计),但在这里是「就地的容量参数」:
1951
+ // 改完立刻 POST,不等设置页那个保存按钮——用户在详情页改完就走人了,
1952
+ // 要求他再去设置页点一次保存,等于挖了个坑。
1953
+ //
1954
+ // 乐观更新 + 失败回滚:先把本地值改成新值(下拉立刻响应),服务端拒绝
1955
+ // (校验 / 磁盘损坏)时退回原值并报红字。不做「保存中禁用」——这是个
1956
+ // 一次性动作,且服务端有写队列,连点几次只会依次落盘。
1957
+ const [retentionPending, setRetentionPending] = React.useState(false)
1958
+ const savedRetention = Number.isFinite(historyMaxEntries) ? historyMaxEntries : HISTORY_MAX_ENTRIES_DEFAULT
1959
+
1960
+ // —— 二次确认与浏览状态(从 SoulSettings 平移,语义不变)——
1961
+ const [pendingUndo, setPendingUndo] = React.useState(null)
1962
+ const [pendingClear, setPendingClear] = React.useState(false)
1963
+ // 展开的是哪一条(存 id 而不是下标:重拉后顺序可能变,存下标会展开错行)。
1964
+ const [expandedEntry, setExpandedEntry] = React.useState(null)
1965
+ // 「还有 N 条」被点开后就一直摊开:记录只在写配置时变。
1966
+ const [showAllHistory, setShowAllHistory] = React.useState(false)
1967
+
1968
+ React.useEffect(() => {
1969
+ if (pendingUndo === null && !pendingClear) return
1970
+ const timer = setTimeout(() => {
1971
+ setPendingUndo(null)
1972
+ setPendingClear(false)
1973
+ }, 5000)
1974
+ return () => clearTimeout(timer)
1975
+ }, [pendingUndo, pendingClear])
1976
+
1977
+ const showError = (text) => {
1978
+ setNotice(null)
1979
+ setErrorNotice(text)
1980
+ }
1981
+ const showNotice = (text) => {
1982
+ setErrorNotice(null)
1983
+ setNotice(text)
1984
+ }
1985
+
1986
+ // 保留条数的就地保存(定义放在 showError / showNotice 之后:它们是被引用的
1987
+ // 绑定,写在前面会在首次渲染时落进 TDZ —— 本文件踩过同一个坑)。
1988
+ const changeRetention = async (next) => {
1989
+ if (retentionPending || next === savedRetention) return
1990
+ setRetentionPending(true)
1991
+ setErrorNotice(null)
1992
+ try {
1993
+ // 只发这一个字段:服务端把 POST 体当 patch 用(`{ ...current, ...patch }`),
1994
+ // 不必回传整份配置 —— 也就不会把别的标签页刚改的字段覆盖回旧值。
1995
+ const payload = await controller.saveConfig({ historyMaxEntries: next })
1996
+ if (!payload) {
1997
+ showError(t('toast.saveFailed'))
1998
+ return
1999
+ }
2000
+ // 服务端会夹取(clampHistoryMaxEntries):回读它**实际接受**的值,
2001
+ // 而不是相信我们发出去的那个。
2002
+ const applied = Number.isFinite(payload.config && payload.config.historyMaxEntries)
2003
+ ? payload.config.historyMaxEntries
2004
+ : savedRetention
2005
+ showNotice(t('history.maxEntriesSaved', { count: String(applied) }))
2006
+ // 窗口可能变小(例如 200 → 50):顺手把列表刷新回新窗口,免得界面上
2007
+ // 还挂着按旧上限读来的条目。
2008
+ await controller.loadHistory()
2009
+ } catch (err) {
2010
+ showError(messageOf(err))
2011
+ } finally {
2012
+ setRetentionPending(false)
2013
+ }
2014
+ }
2015
+
2016
+ const handleUndo = async (id) => {
2017
+ if (pendingUndo !== id) {
2018
+ setPendingUndo(id)
2019
+ return
2020
+ }
2021
+ setPendingUndo(null)
2022
+ try {
2023
+ const payload = await controller.rollbackHistory(id)
2024
+ await controller.loadHistory()
2025
+ const count = Array.isArray(payload && payload.reversed) ? payload.reversed.length : 0
2026
+ if (count === 0) {
2027
+ showNotice(t('history.restoreUnchanged'))
2028
+ } else {
2029
+ showNotice(t('history.restoreDone', { count: String(count) }))
2030
+ }
2031
+ } catch (err) {
2032
+ showError(messageOf(err))
2033
+ }
2034
+ }
2035
+
2036
+ const handleRestorePreset = async (id) => {
2037
+ try {
2038
+ const payload = await controller.restorePersona(id)
2039
+ await controller.loadHistory()
2040
+ const restored = Array.isArray(payload && payload.restored) ? payload.restored : []
2041
+ const skipped = Array.isArray(payload && payload.skipped) ? payload.skipped : []
2042
+ const names = restored.join(t('common.listSep'))
2043
+ showNotice(
2044
+ skipped.length > 0
2045
+ ? `${t('history.restorePresetDone', { name: names })} ${t('history.restoreSkippedNote', { count: String(restored.length), skipped: String(skipped.length) })}`
2046
+ : t('history.restorePresetDone', { name: names })
2047
+ )
2048
+ } catch (err) {
2049
+ showError(messageOf(err))
2050
+ }
2051
+ }
2052
+
2053
+ const handleClearHistory = async () => {
2054
+ if (!pendingClear) {
2055
+ setPendingClear(true)
2056
+ return
2057
+ }
2058
+ setPendingClear(false)
2059
+ try {
2060
+ const payload = await controller.clearHistory()
2061
+ await controller.loadHistory()
2062
+ const removed = payload && typeof payload.removed === 'number' ? payload.removed : 0
2063
+ showNotice(t('history.clearDone', { count: String(removed) }))
2064
+ } catch (err) {
2065
+ showError(messageOf(err))
2066
+ }
2067
+ }
2068
+
2069
+ // 挂载即拉取:详情页本来就是「专门来看记录」才打开的页面。
2070
+ React.useEffect(() => {
2071
+ if (!controller.canRefresh()) return
2072
+ void controller.loadHistory()
2073
+ }, [controller])
2074
+
2075
+ // —— 渲染辅助(与 0.8.0-d 同源,搬成组件内局部函数)——
2076
+ const historySummary = historyError
2077
+ ? t('accordion.loadFailed')
2078
+ : (history === null
2079
+ ? t('status.loading')
2080
+ : (history.length === 0
2081
+ ? t('accordion.empty')
2082
+ : (Number.isFinite(historyMax)
2083
+ ? t('history.summary', { count: String(history.length), max: String(historyMax) })
2084
+ : t('history.summaryCount', { count: String(history.length) }))))
2085
+
2086
+ const historyTimeText = (rawAt) => {
2087
+ const at = Date.parse(rawAt)
2088
+ if (!Number.isFinite(at)) return String(rawAt)
2089
+ const diff = Date.now() - at
2090
+ // 时钟回拨(或服务端时间略快)会让 diff 变成负数 —— 当成「刚刚」。
2091
+ if (diff < 60 * 1000) return t('history.timeJustNow')
2092
+ const minutes = Math.floor(diff / (60 * 1000))
2093
+ if (minutes < 60) return t('history.timeMinutes', { count: String(minutes) })
2094
+ const hours = Math.floor(minutes / 60)
2095
+ if (hours < 24) return t('history.timeHours', { count: String(hours) })
2096
+ return t('history.timeDays', { count: String(Math.floor(hours / 24)) })
2097
+ }
2098
+
2099
+ const historySourceText = (source) => {
2100
+ const key = `history.source.${String(source)}`
2101
+ return HISTORY_SOURCES.includes(source) ? t(key) : String(source)
2102
+ }
2103
+
2104
+ const historyValueText = (key, value) => {
2105
+ if (value === undefined || value === null || value === '') return t('history.valueEmpty')
2106
+ if (typeof value === 'boolean') return t(value ? 'accordion.on' : 'accordion.off')
2107
+ if (key === 'language') {
2108
+ const hit = LANGUAGE_OPTIONS.find((item) => item.value === value)
2109
+ return hit ? hit.label : String(value)
2110
+ }
2111
+ // 只有落在合法取值里才查词典:否则 `style: 'custom-whatever'` 会渲染成键名本身
2112
+ const toKey = HISTORY_ENUM_KEYS[key]
2113
+ if (toKey && HISTORY_ENUM_VALUES[key].includes(value)) return t(toKey(value))
2114
+ return String(value)
2115
+ }
2116
+
2117
+ const historyFieldNames = (entry) => {
2118
+ const keys = new Set()
2119
+ for (const key of Object.keys(entry.before || {})) keys.add(key)
2120
+ for (const key of Object.keys(entry.after || {})) keys.add(key)
2121
+ return [...keys]
2122
+ }
2123
+
2124
+ const historyPersonaEvents = (entry) => {
2125
+ const events = (entry && entry.personas) || {}
2126
+ const added = Array.isArray(events.added) ? events.added : []
2127
+ const removed = Array.isArray(events.removed) ? events.removed.map((item) => item && item.name).filter(Boolean) : []
2128
+ const updated = Array.isArray(events.updated) ? events.updated.map((item) => item && item.name).filter(Boolean) : []
2129
+ return { added, removed, updated }
2130
+ }
2131
+
2132
+ const historyRowText = (entry) => {
2133
+ const parts = historyFieldNames(entry).map((key) => t(HISTORY_FIELD_LABELS[key] || key))
2134
+ const events = historyPersonaEvents(entry)
2135
+ const sep = t('common.listSep')
2136
+ if (events.added.length > 0) parts.push(t('history.personasAdded', { names: events.added.join(sep) }))
2137
+ if (events.removed.length > 0) parts.push(t('history.personasRemoved', { names: events.removed.join(sep) }))
2138
+ if (events.updated.length > 0) parts.push(t('history.personasUpdated', { names: events.updated.join(sep) }))
2139
+ // label(如预设名)紧跟在第一个字段前说出来:「应用预设:苏格拉底式提问者 · 自定义指令」
2140
+ // —— 没有它,这一行答不了「应用的是什么」。旧记录没有 label,原样省略。
2141
+ if (typeof entry.label === 'string' && entry.label !== '') parts.unshift(t('history.labelSuffix', { label: entry.label }))
2142
+ return parts.length > 0 ? t('history.fields', { fields: parts.join(sep) }) : t('history.emptyFields')
2143
+ }
2144
+
2145
+ const historyFieldRows = (entry) => {
2146
+ const before = (entry && entry.before) || {}
2147
+ const after = (entry && entry.after) || {}
2148
+ const keys = [...new Set([...Object.keys(after), ...Object.keys(before)])]
2149
+ return keys.map((key) => ({
2150
+ key,
2151
+ label: t(HISTORY_FIELD_LABELS[key] || key),
2152
+ from: historyValueText(key, before[key]),
2153
+ to: historyValueText(key, after[key])
2154
+ }))
2155
+ }
2156
+
2157
+ // —— 外壳对齐宿主插件详情页(fO69Vq_ 前缀的样式)——
2158
+ // .detailSection:flex column、gap:12px;.sectionHead:baseline 对齐的标题行;
2159
+ // .sectionTitle:14px/500/20px;.sectionCount:12px 次要色。
2160
+ return e('section', { className: 'soul-detail-history', 'data-soul-history': 'true' },
2161
+ e('div', { className: 'soul-detail-history-head' },
2162
+ e('h4', { className: 'soul-detail-history-title' }, t('group.history')),
2163
+ e('span', { className: 'soul-detail-history-count' }, historySummary)
2164
+ ),
2165
+ // —— 保留条数:与它约束的内容放在一起 ——
2166
+ //
2167
+ // 标题行说「最近 37 条 · 最多保留 200 条」,这个 200 就地可改,不必去设置页找。
2168
+ // 放在列表**之前**而不是之后:它是这个区块的容量设置,读了标题就会想知道它在哪;
2169
+ // 放到几十条记录下面,等于让用户先翻完才看见。
2170
+ // 记录为空 / 读取失败时也照常渲染 —— 容量参数与「当前有几条」无关。
2171
+ e('div', { className: 'soul-history-retention' },
2172
+ e('label', { htmlFor: 'soul-historyMaxEntries' }, t('history.maxEntries')),
2173
+ e('select', {
2174
+ id: 'soul-historyMaxEntries',
2175
+ value: String(savedRetention),
2176
+ disabled: retentionPending,
2177
+ onChange: (ev) => { void changeRetention(Number(ev.target.value)) }
2178
+ },
2179
+ ...HISTORY_MAX_ENTRIES_OPTIONS.map((count) => e('option', {
2180
+ key: count,
2181
+ value: String(count)
2182
+ }, count === HISTORY_MAX_ENTRIES_DEFAULT
2183
+ ? t('history.maxEntriesDefault', { count: String(count) })
2184
+ : t('history.maxEntriesOption', { count: String(count) })))
2185
+ ),
2186
+ e(SoulHint, { text: t('history.maxEntriesHint') })
2187
+ ),
2188
+ // 结果提示:成功绿字 / 失败红字,与设置页同一套形态(宿主 savedNotice 语义)
2189
+ (notice || errorNotice) && (errorNotice
2190
+ ? e('p', { className: 'soul-error-notice', role: 'alert' }, errorNotice)
2191
+ : e('p', { className: 'soul-saved-notice', role: 'status', 'aria-live': 'polite' }, notice)),
2192
+ historyError
2193
+ ? e('div', { className: 'soul-error' }, t('history.loadFailed', { reason: historyError }))
2194
+ : (history === null
2195
+ ? e('div', { className: 'soul-status' }, t('history.loading'))
2196
+ : (history.length === 0
2197
+ ? e('div', { className: 'soul-status' }, t('history.empty'))
2198
+ : e(Fragment, null,
2199
+ // 清空是**不可逆**的(记录里有昵称、职业、自述、自定义指令的原文),
2200
+ // 所以它是这一组唯一的危险色按钮,且同样要过二次确认。
2201
+ e('div', { className: 'soul-history-head' },
2202
+ e('button', {
2203
+ type: 'button',
2204
+ className: `soul-history-clear${pendingClear ? ' soul-history-clear-armed' : ''}`,
2205
+ onClick: handleClearHistory
2206
+ }, t(pendingClear ? 'history.clearArmed' : 'history.clear'))
2207
+ ),
2208
+
2209
+ // 记录可能不完整(末尾被截断 / 文件过大只读了一段 / 有坏行)——
2210
+ // 这是「看到的不是全部」,不是失败。
2211
+ historyWarning && e('div', { className: 'soul-status soul-history-warning' },
2212
+ t('history.warning', { reason: historyWarning })),
2213
+
2214
+ e('ul', { className: 'soul-history-list' },
2215
+ ...(showAllHistory ? history : history.slice(0, HISTORY_RENDER_LIMIT)).map((entry) => {
2216
+ const isOpen = expandedEntry === entry.id
2217
+ const events = historyPersonaEvents(entry)
2218
+ const rows = historyFieldRows(entry)
2219
+ const arming = pendingUndo === entry.id
2220
+ return e('li', {
2221
+ key: entry.id,
2222
+ className: 'soul-history-item',
2223
+ 'data-source': String(entry.source)
2224
+ },
2225
+ e('div', { className: 'soul-history-item-head' },
2226
+ // 整行主体是一个按钮(展开/收起),操作按钮放在它**外面** ——
2227
+ // 按钮里套按钮既非法又会让点「撤销」连带展开。
2228
+ e('button', {
2229
+ type: 'button',
2230
+ className: 'soul-history-item-main',
2231
+ 'aria-expanded': isOpen,
2232
+ onClick: () => setExpandedEntry(isOpen ? null : entry.id)
2233
+ },
2234
+ e('span', { className: 'soul-history-time' }, historyTimeText(entry.at)),
2235
+ e('span', { className: 'soul-history-source' }, historySourceText(entry.source)),
2236
+ e('span', { className: 'soul-history-fields' }, historyRowText(entry))
2237
+ ),
2238
+ e('div', { className: 'soul-history-item-actions' },
2239
+ // 撤销用次要色:它可逆;真正不可逆的是清空,危险色留给它。
2240
+ e('button', {
2241
+ type: 'button',
2242
+ className: `soul-history-action${arming ? ' soul-history-action-armed' : ''}`,
2243
+ onClick: () => handleUndo(entry.id)
2244
+ }, t(arming ? 'history.restoreArmed' : 'history.restore')),
2245
+ // 只有「删过预设」的记录才给恢复入口 —— 其余记录点它无事可做。
2246
+ events.removed.length > 0 && e('button', {
2247
+ type: 'button',
2248
+ className: 'soul-history-action',
2249
+ onClick: () => handleRestorePreset(entry.id)
2250
+ }, t('history.restorePreset'))
2251
+ )
2252
+ ),
2253
+
2254
+ isOpen && e('div', { className: 'soul-history-detail' },
2255
+ rows.length > 0 && e('div', { className: 'soul-history-diff' },
2256
+ // 「展开即全文」:原值/新值不再压成单行省略 —— 自定义指令几百字时,
2257
+ // 一行省略号等于没给。每行竖排:字段名 + 原值块 + 新值块,长文本换行铺开。
2258
+ ...rows.map((row) => e('div', { key: row.key, className: 'soul-history-diff-row' },
2259
+ e('span', { className: 'soul-history-diff-field' }, row.label),
2260
+ row.from !== '' && e('div', { className: 'soul-history-diff-cell' },
2261
+ e('span', { className: 'soul-history-diff-tag' }, t('history.field.from')),
2262
+ e('div', { className: 'soul-history-diff-value' }, row.from)
2263
+ ),
2264
+ e('div', { className: 'soul-history-diff-cell' },
2265
+ e('span', { className: 'soul-history-diff-tag' }, t('history.field.to')),
2266
+ e('div', { className: 'soul-history-diff-value' }, row.to)
2267
+ )
2268
+ ))
2269
+ ),
2270
+ events.added.length > 0 && e('div', { className: 'soul-history-diff-persona' },
2271
+ t('history.personasAdded', { names: events.added.join(t('common.listSep')) })),
2272
+ events.updated.length > 0 && e('div', { className: 'soul-history-diff-persona' },
2273
+ t('history.personasUpdated', { names: events.updated.join(t('common.listSep')) })),
2274
+ events.removed.length > 0 && e('div', { className: 'soul-history-diff-persona' },
2275
+ t('history.personasRemoved', { names: events.removed.join(t('common.listSep')) }))
2276
+ )
2277
+ )
2278
+ })
2279
+ ),
2280
+
2281
+ // 「最近 200 条」这句承诺得配一个能看到更多的出口。
2282
+ !showAllHistory && history.length > HISTORY_RENDER_LIMIT && e('button', {
2283
+ type: 'button',
2284
+ className: 'soul-history-more',
2285
+ onClick: () => setShowAllHistory(true)
2286
+ }, t('history.more', { count: String(history.length - HISTORY_RENDER_LIMIT) })),
2287
+
2288
+ e('div', { className: 'soul-status' }, t('history.footer'))
2289
+ )))
2290
+ )
2291
+ }
2292
+
1319
2293
  // 输入框工具栏快捷开关:复用全局 soulController,和设置页保持同一份状态。
1320
2294
  function SoulQuickToggle(props) {
1321
2295
  const { useSoulController, controller } = props
@@ -1365,12 +2339,322 @@ window.__ModuleLoader__.load({
1365
2339
  )
1366
2340
  }
1367
2341
 
1368
- function SoulSettings(props) {
2342
+ // -------------------------------------------------------------------------
2343
+ // 设置页「工作区绑定」分组(0.8.1)
2344
+ // -------------------------------------------------------------------------
2345
+
2346
+ // 目录点选服务。由 apply() 里的 ctx.inject(['uiWorkspace'], ...) 动态写入 ——
2347
+ // **不能**把它加进静态 inject 数组(`const inject = ['slots','locale']`):
2348
+ // uiWorkspace 缺失时整个插件都不会激活,设置页连同人设卡一起消失。
2349
+ // null = 这个连接拿不到该服务,界面直接走手输路径。
2350
+ let directoryPicker = null
2351
+
2352
+ // 路径截中段显示:保留结尾(`…\dsh-plugin`)。用户靠结尾区分项目、靠开头区分盘符,
2353
+ // 两者都要时优先结尾 —— 同一盘下的项目前缀往往一样长。完整路径交给 title。
2354
+ function shortenPath(raw, max) {
2355
+ const text = typeof raw === 'string' ? raw : String(raw == null ? '' : raw)
2356
+ const limit = Number.isFinite(max) ? max : 44
2357
+ if (text.length <= limit) return text
2358
+ const tail = text.slice(text.length - (limit - 1))
2359
+ // 从第一个分隔符处切,避免留下半截目录名(`…-plugin` 比 `…\dsh-plugin` 难认)
2360
+ const cut = Math.max(tail.indexOf('\\'), tail.indexOf('/'))
2361
+ return '…' + (cut >= 0 ? tail.slice(cut) : tail)
2362
+ }
2363
+
2364
+ // 「工作区绑定」主体。三块结构见 DESIGN-persona-scope.md §7.3:
2365
+ // ① 当前会话目录卡(我这个项目现在是谁)② 绑定列表(增删改查)③ 未绑定回落行。
2366
+ //
2367
+ // 与变更记录同款**懒加载**:本组件只在分组展开时才被渲染(手风琴 body 的
2368
+ // `open && ...`),所以挂载即等于「用户主动打开了它」,effect 里拉一次即可。
2369
+ // 刻意不进 2 秒轮询 —— 绑定是配置不是状态,轮询只会让「手机上改了、桌面上跳」。
2370
+ function SoulBindings(props) {
2371
+ const { useSoulController, controller, personaNames, onOpenPersonas } = props
2372
+ const t = typeof props.t === 'function' ? props.t : FALLBACK_T
2373
+ const state = useSoulController((state) => state)
2374
+ const {
2375
+ bindings, bindingsInvalid, bindingsDropped, bindingsWarning, bindingsError,
2376
+ bindingsMax, bindingsMaxPath, bindingsSessions, activePersona
2377
+ } = state
2378
+
2379
+ // 下拉选项:内置优先的稳定顺序由 SoulSettings 算好传进来(同一份排序)。
2380
+ const names = Array.isArray(personaNames) ? personaNames : []
2381
+ const [draftDir, setDraftDir] = React.useState('')
2382
+ const [draftPersona, setDraftPersona] = React.useState('')
2383
+ const [pickerFailed, setPickerFailed] = React.useState(false)
2384
+ const [pendingUnbind, setPendingUnbind] = React.useState(null)
2385
+ const [notice, setNotice] = React.useState(null)
2386
+ const [errorNotice, setErrorNotice] = React.useState(null)
2387
+
2388
+ React.useEffect(() => {
2389
+ if (!controller.canRefresh()) return
2390
+ void controller.loadBindings()
2391
+ }, [])
2392
+
2393
+ // 解绑的二次确认 5 秒后自动复位(与删除预设同一套 armed 态,不用原生 confirm)
2394
+ React.useEffect(() => {
2395
+ if (pendingUnbind === null) return undefined
2396
+ const timer = setTimeout(() => setPendingUnbind(null), 5000)
2397
+ return () => clearTimeout(timer)
2398
+ }, [pendingUnbind])
2399
+
2400
+ const showError = (text) => { setErrorNotice(text); setNotice(null) }
2401
+ const showNotice = (text) => { setNotice(text); setErrorNotice(null) }
2402
+
2403
+ // 草稿人设默认取列表第一项 —— 下拉不能空着等用户点,那看着像坏了
2404
+ const effectiveDraftPersona = names.includes(draftPersona)
2405
+ ? draftPersona
2406
+ : (names.length > 0 ? names[0] : '')
2407
+
2408
+ // 调起系统目录选择器。取消返回空串(什么都不做),失败抛错。
2409
+ //
2410
+ // 降级判据必须是「调用失败」而不是「预读后端种类」:宿主 UiWorkspaceService
2411
+ // 的 pickDirectory() 把底层错误(含 capability 字段)包装成了普通 Error,
2412
+ // 客户端读不到种类。见 DESIGN-persona-scope.md §7.5。
2413
+ async function handlePick() {
2414
+ try {
2415
+ // 防御:服务可能在按钮渲染后被撤走(插件卸载 / scope 物化),此时按「不可用」处理
2416
+ if (!directoryPicker) throw new Error(t('bindings.pickerUnavailable'))
2417
+ const picked = await directoryPicker.pickDirectory()
2418
+ // null = 用户取消:不是错误,不该弹红字
2419
+ if (typeof picked === 'string' && picked !== '') setDraftDir(picked)
2420
+ } catch (err) {
2421
+ setPickerFailed(true)
2422
+ showError(messageOf(err))
2423
+ }
2424
+ }
2425
+
2426
+ async function handleBind() {
2427
+ const dir = draftDir.trim()
2428
+ if (dir === '') {
2429
+ showError(t('bindings.needDir'))
2430
+ return
2431
+ }
2432
+ try {
2433
+ await controller.saveBinding(dir, effectiveDraftPersona)
2434
+ setDraftDir('')
2435
+ showNotice(t('bindings.bound', { dir: shortenPath(dir), name: effectiveDraftPersona }))
2436
+ } catch (err) {
2437
+ // 服务端的拒绝文案可以直接展示(它已经写清了是哪一类问题),不再包一层前缀
2438
+ showError(messageOf(err))
2439
+ }
2440
+ }
2441
+
2442
+ async function handleRebind(dir, persona) {
2443
+ try {
2444
+ await controller.saveBinding(dir, persona)
2445
+ showNotice(t('bindings.bound', { dir: shortenPath(dir), name: persona }))
2446
+ } catch (err) {
2447
+ showError(messageOf(err))
2448
+ }
2449
+ }
2450
+
2451
+ async function handleUnbind(dir) {
2452
+ if (pendingUnbind !== dir) {
2453
+ setPendingUnbind(dir)
2454
+ setErrorNotice(null)
2455
+ return
2456
+ }
2457
+ setPendingUnbind(null)
2458
+ try {
2459
+ const payload = await controller.removeBinding(dir)
2460
+ // removed 为假 = 这条绑定已经不在表里了(另一个窗口删的),不必谎报成功
2461
+ if (payload && payload.removed === false) showError(t('bindings.unbindMissing'))
2462
+ else showNotice(t('bindings.unbound', { dir: shortenPath(dir) }))
2463
+ } catch (err) {
2464
+ showError(messageOf(err))
2465
+ }
2466
+ }
2467
+
2468
+ const table = bindings && typeof bindings === 'object' && !Array.isArray(bindings) ? bindings : {}
2469
+ const dirs = Object.keys(table)
2470
+ // 失效引用:服务端按当前预设库算好后标注,客户端只负责画出来(不自动删除 ——
2471
+ // 用户可能只是临时删了那个预设、打算改名回来)
2472
+ const invalidNames = new Map()
2473
+ for (const item of Array.isArray(bindingsInvalid) ? bindingsInvalid : []) {
2474
+ if (item && typeof item.dir === 'string') invalidNames.set(item.dir, String(item.persona))
2475
+ }
2476
+ // 层级排序:父目录排在子目录前,用 `└` 标出「这是上一条的子树」。
2477
+ // 同一个 `D:\项目A` 既绑了自身又绑了子目录时,先看父再看子才读得懂继承关系。
2478
+ const sortedDirs = dirs.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
2479
+
2480
+ // 当前会话:优先挑有 cwd 的那个 —— 没有目录的会话任何绑定都不会命中,
2481
+ // 拿它当「当前」会让整张卡说的都不是用户眼前这个项目。
2482
+ const sessions = Array.isArray(bindingsSessions) ? bindingsSessions : []
2483
+ const withCwd = sessions.filter((s) => s && typeof s.cwd === 'string' && s.cwd !== '')
2484
+ const current = withCwd.length > 0 ? withCwd[0] : (sessions.length > 0 ? sessions[0] : null)
2485
+ const currentCwd = current && typeof current.cwd === 'string' && current.cwd !== '' ? current.cwd : null
2486
+ const moreCount = sessions.length - (current ? 1 : 0)
2487
+ const binding = current && current.binding ? current.binding : null
2488
+ const effectivePersona = current && current.persona ? String(current.persona) : ''
2489
+ // 「直接绑在本目录」与「继承自上级」的区别只体现在这里:没有它,用户在子目录里
2490
+ // 看到别的人设会以为是 bug。判别已在宿主做过(客户端拿原始串比较会被盘符大小写骗过)。
2491
+ const sourceText = !binding
2492
+ ? t('bindings.sourceGlobal')
2493
+ : (binding.inherited
2494
+ ? t('bindings.sourceInherited', { dir: shortenPath(binding.dir) })
2495
+ : shortenPath(binding.dir))
2496
+
2497
+ return e('div', { className: 'soul-bindings' },
2498
+ // ① 当前会话目录卡 —— 先回答「我这个项目现在是谁」,再让用户改
2499
+ e('div', { className: 'soul-field' },
2500
+ e('div', { className: 'soul-field-label' }, t('bindings.currentTitle')),
2501
+ current === null
2502
+ ? e('div', { className: 'soul-status' }, t('bindings.noSession'))
2503
+ : (currentCwd === null
2504
+ ? e('div', { className: 'soul-status' }, t('bindings.noCwd'))
2505
+ : e('div', null,
2506
+ e('div', { className: 'soul-bindings-current' },
2507
+ e('code', {
2508
+ className: 'soul-bindings-path',
2509
+ title: currentCwd
2510
+ }, shortenPath(currentCwd)),
2511
+ e('div', { className: 'soul-bindings-meta' },
2512
+ e('div', null, `${t('bindings.effective')}:${effectivePersona || t('quick.custom')}`),
2513
+ e('div', null, `${t('bindings.source')}:${sourceText}`)
2514
+ )
2515
+ ),
2516
+ moreCount > 0 && e('div', { className: 'soul-status' },
2517
+ t('bindings.moreSessions', { count: String(moreCount) }))
2518
+ ))
2519
+ ),
2520
+
2521
+ // ② 新建 / 改绑入口。点选与手输填的是**同一个字段** —— 选择器只是把路径
2522
+ // 填进来,不是另一条独立的路径,所以两者不会给出不同的结果。
2523
+ e('div', { className: 'soul-field' },
2524
+ e('label', { htmlFor: 'soul-bindings-dir' }, t('bindings.manualLabel')),
2525
+ e('div', { className: 'soul-bindings-new' },
2526
+ e('input', {
2527
+ type: 'text',
2528
+ id: 'soul-bindings-dir',
2529
+ value: draftDir,
2530
+ placeholder: t('bindings.manualPlaceholder'),
2531
+ onChange: (ev) => setDraftDir(ev.target.value)
2532
+ }),
2533
+ // 失败过一次就不再给按钮:宿主的能力在启动时判定一次、之后不会变,
2534
+ // 留着它只会让用户反复点一个确定会失败的东西。
2535
+ // 服务**压根没注入**时同理(那些连接方式拿不到 uiWorkspace):按钮不能出现,
2536
+ // 否则点下去只会在 handlePick 里对 null 取属性、抛一个用户看不懂的 TypeError。
2537
+ !pickerFailed && directoryPicker !== null && e('button', {
2538
+ type: 'button',
2539
+ className: 'soul-bindings-pick',
2540
+ onClick: () => { void handlePick() }
2541
+ }, t('bindings.choose'))
2542
+ ),
2543
+ pickerFailed && e('div', { className: 'soul-bindings-hint' }, t('bindings.pickerUnavailable')),
2544
+ e('div', { className: 'soul-bindings-new' },
2545
+ e('label', { htmlFor: 'soul-bindings-persona' }, t('bindings.persona')),
2546
+ e('select', {
2547
+ id: 'soul-bindings-persona',
2548
+ value: effectiveDraftPersona,
2549
+ onChange: (ev) => setDraftPersona(ev.target.value)
2550
+ }, names.map((name) => e('option', { key: name, value: name }, name))),
2551
+ e('button', {
2552
+ type: 'button',
2553
+ className: 'soul-bindings-bind',
2554
+ disabled: names.length === 0,
2555
+ onClick: () => { void handleBind() }
2556
+ }, t('bindings.bindDir'))
2557
+ ),
2558
+ bindingsMaxPath && e('div', { className: 'soul-status' },
2559
+ t('bindings.maxPath', { max: String(bindingsMaxPath) }))
2560
+ ),
2561
+
2562
+ // ③ 绑定列表
2563
+ e('div', { className: 'soul-field' },
2564
+ e('div', { className: 'soul-field-label' }, t('bindings.listTitle')),
2565
+ bindingsError
2566
+ ? e('div', { className: 'soul-error' }, t('bindings.loadFailed', { reason: bindingsError }))
2567
+ : (bindings === null
2568
+ ? e('div', { className: 'soul-status' }, t('bindings.loading'))
2569
+ : (sortedDirs.length === 0
2570
+ ? e('div', { className: 'soul-status' }, t('bindings.empty'))
2571
+ : e('div', null,
2572
+ e('div', { className: 'soul-status' },
2573
+ t('bindings.count', {
2574
+ count: String(sortedDirs.length),
2575
+ max: Number.isFinite(bindingsMax) ? String(bindingsMax) : '—'
2576
+ })),
2577
+ e('ul', { className: 'soul-bindings-rows' },
2578
+ sortedDirs.map((dir, at) => {
2579
+ // 与前面渲染过的某条构成父子关系时画 `└`,把「一条绑定 =
2580
+ // 一棵子树」画出来 —— 否则用户看不出为什么两个目录会用同一个人设
2581
+ const parent = sortedDirs.slice(0, at).find((prev) => (
2582
+ dir.startsWith(prev + '\\') || dir.startsWith(prev + '/')
2583
+ ))
2584
+ const isCurrent = currentCwd !== null && (
2585
+ currentCwd === dir ||
2586
+ currentCwd.startsWith(dir + '\\') ||
2587
+ currentCwd.startsWith(dir + '/')
2588
+ )
2589
+ const invalidName = invalidNames.get(dir)
2590
+ const value = invalidName || String(table[dir])
2591
+ const isInvalid = invalidName !== undefined
2592
+ return e('li', {
2593
+ key: dir,
2594
+ className: 'soul-bindings-row',
2595
+ 'data-current': isCurrent ? 'true' : 'false'
2596
+ },
2597
+ parent && e('span', { className: 'soul-bindings-tree', 'aria-hidden': true }, '└'),
2598
+ e('code', { className: 'soul-bindings-dir', title: dir }, shortenPath(dir)),
2599
+ isCurrent && e('span', { className: 'soul-bindings-here' }, t('bindings.here')),
2600
+ isInvalid && e('span', { className: 'soul-bindings-bad' },
2601
+ t('bindings.invalid', { name: invalidName })),
2602
+ // 失效引用也画在下拉里(作为唯一选项):让用户看到「现在写的是谁」,
2603
+ // 而不是看见一个空下拉、以为这条记录没有值
2604
+ e('select', {
2605
+ className: 'soul-bindings-select',
2606
+ value,
2607
+ 'aria-label': t('bindings.persona'),
2608
+ onChange: (ev) => { void handleRebind(dir, ev.target.value) }
2609
+ },
2610
+ isInvalid && e('option', { value }, invalidName),
2611
+ names.map((name) => e('option', { key: name, value: name }, name))
2612
+ ),
2613
+ e('button', {
2614
+ type: 'button',
2615
+ className: pendingUnbind === dir
2616
+ ? 'soul-bindings-action soul-bindings-action-armed'
2617
+ : 'soul-bindings-action',
2618
+ onClick: () => { void handleUnbind(dir) }
2619
+ }, pendingUnbind === dir ? t('bindings.unbindArmed') : t('bindings.unbind'))
2620
+ )
2621
+ })
2622
+ )
2623
+ ))
2624
+ ),
2625
+
2626
+ // ④ 未绑定回落行:把隐式行为显式写成一条可改的规则
2627
+ e('div', { className: 'soul-bindings-fallback' },
2628
+ e('span', { className: 'soul-bindings-fallback-text' },
2629
+ `${t('bindings.fallback')} · ${activePersona || t('quick.custom')}`),
2630
+ e('button', {
2631
+ type: 'button',
2632
+ className: 'soul-bindings-action',
2633
+ onClick: onOpenPersonas
2634
+ }, t('bindings.change'))
2635
+ ),
2636
+
2637
+ // 「看到的不是全部」与「失败了」是两件事:前者是黄字警告,后者是红字
2638
+ Number.isFinite(bindingsDropped) && bindingsDropped > 0 && e('div', { className: 'soul-status' },
2639
+ t('bindings.dropped', { count: String(bindingsDropped) })),
2640
+ bindingsWarning && e('div', { className: 'soul-status soul-bindings-warning' },
2641
+ t('bindings.warning', { reason: bindingsWarning })),
2642
+ errorNotice && e('p', { className: 'soul-error-notice', role: 'alert' }, errorNotice),
2643
+ !errorNotice && notice && e('p', {
2644
+ className: 'soul-saved-notice',
2645
+ role: 'status',
2646
+ 'aria-live': 'polite'
2647
+ }, notice)
2648
+ )
2649
+ )
2650
+ }
2651
+
2652
+ function SoulSettings(props) {
1369
2653
  const { useSoulController, controller } = props
1370
2654
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
1371
2655
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
1372
2656
  const state = useSoulController((state) => state)
1373
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, personasError, saving, error } = state
2657
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, historyMaxEntries, personas, activePersona, personasError, history, historyWarning, historyMax, historyError, saving, error, bindings, bindingsMax, bindingsWarning, bindingsError, bindingsInvalid, bindingsDropped, bindingsSessions } = state
1374
2658
 
1375
2659
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
1376
2660
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
@@ -1388,6 +2672,10 @@ window.__ModuleLoader__.load({
1388
2672
  const [localTrailColor, setLocalTrailColor] = React.useState(trailColor || TRAIL_COLOR_FALLBACK)
1389
2673
  const [localTrailSpeed, setLocalTrailSpeed] = React.useState(trailSpeed || 'slow')
1390
2674
  const [localTrailWidth, setLocalTrailWidth] = React.useState(trailWidth || 'thin')
2675
+ // 变更记录保留条数**不再是设置页的表单字段**(0.8.1 迁到插件详情页的变更记录
2676
+ // 区块,就地改、立刻落盘)。因此它不进 localMap / savedMap / dirty ——
2677
+ // 否则「在详情页改了容量」会让设置页的保存按钮莫名亮起,且点保存会把那份
2678
+ // 详情页刚改的值当成「用户在这里的编辑」再提交一次。
1391
2679
  const [personaName, setPersonaName] = React.useState('')
1392
2680
  // 「存为预设」的命名弹层是否打开。
1393
2681
  //
@@ -1539,7 +2827,7 @@ window.__ModuleLoader__.load({
1539
2827
  : a === b)
1540
2828
  const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
1541
2829
  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 }
1542
- const dirty = FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
2830
+ const dirty = FORM_FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
1543
2831
 
1544
2832
  // 宿主 savedNotice 语义:一旦用户开始新的编辑(dirty 变 true),
1545
2833
  // 上一条「已保存」就过时了 ⇒ 清除。(effect 必须放在 dirty 定义之后:
@@ -1730,7 +3018,7 @@ window.__ModuleLoader__.load({
1730
3018
  return () => clearTimeout(timer)
1731
3019
  }, [pendingDelete])
1732
3020
 
1733
- // —— 卡背(详情面)用到的三个辅助 ——
3021
+ // —— 卡背(详情面)用到的辅助 ——
1734
3022
  //
1735
3023
  // 列出「一个人设完整的六个维度」。顺序与设置页表单一致(风格 → 标题列表 →
1736
3024
  // 表情 → 表格 → 回复长度 → 语言),这样卡背读起来与设置页对得上。
@@ -1818,6 +3106,20 @@ window.__ModuleLoader__.load({
1818
3106
  const personasSummary = personasError
1819
3107
  ? t('accordion.loadFailed')
1820
3108
  : (personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`)
3109
+ // 工作区绑定摘要:折叠态只看得到这一行,所以要先说「读没读出来」,
3110
+ // 再说条数。null = 还没拉过(本分组懒加载,展开前一直是 null)⇒ 显示空态文案,
3111
+ // 不显示「0 条」—— 那会把「没加载」误报成「一条都没有」。
3112
+ //
3113
+ // 0.8.1 分组已从设置页撤下,这里暂时无人引用;保留是因为 0.9.0 的工作区
3114
+ // 平铺卡片要用同一套「摘要怎么说」的判断(加载中 / 失败 / 0 条 / N 条)。
3115
+ // 删掉它只会让那段逻辑在 0.9.0 被重写一遍,而那正是容易漏掉「加载中≠空」的地方。
3116
+ const bindingsSummary = bindingsError
3117
+ ? t('accordion.loadFailed')
3118
+ : (bindings === null || !bindings || typeof bindings !== 'object' || Array.isArray(bindings)
3119
+ ? t('bindings.empty')
3120
+ : (Object.keys(bindings).length === 0
3121
+ ? t('bindings.empty')
3122
+ : t('bindings.count', { count: String(Object.keys(bindings).length), max: Number.isFinite(bindingsMax) ? String(bindingsMax) : '—' })))
1821
3123
  const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
1822
3124
  const promptSummary = promptText.length > 0
1823
3125
  ? t(promptIsDraft ? 'prompt.summaryDraft' : 'prompt.summaryChars', { n: String(promptText.length) })
@@ -2019,6 +3321,9 @@ window.__ModuleLoader__.load({
2019
3321
  open: openSection === 'personas',
2020
3322
  onToggle: toggleSection('personas')
2021
3323
  },
3324
+ // 保留条数下拉不在这里 —— 0.8.1 起它跟着变更记录走,落在**插件详情页**
3325
+ // 的变更记录区块里(容量参数与被它约束的内容放在一起才说得通)。
3326
+ // 设置页这一组只负责预设本身与那句去向指引。
2022
3327
  e('div', { className: 'soul-field' },
2023
3328
  // 加载失败优先于「加载中」:否则失败时界面永远停在加载态,与「还没有预设」无从区分
2024
3329
  personasError
@@ -2292,9 +3597,47 @@ window.__ModuleLoader__.load({
2292
3597
  onClick: () => { setPersonaName(''); setShowSavePersona(true) }
2293
3598
  }, t('personas.driftAction'))
2294
3599
  ),
2295
- e('div', { className: 'soul-status' }, t('personas.hint'))
3600
+ // —— 说明:一条轻提示 + 一个可点的去向,不再铺两行正文 ——
3601
+ //
3602
+ // 原来这里是两行灰字,每行都在复述用户已经能从界面里看到的东西:
3603
+ // ·「风格 / 特质 / 回复长度 / 输出语言 / 自定义指令」—— 就是卡片上那几行;
3604
+ // ·「内置预设不可删除」—— 每张内置卡都有「内置」徽标、且没有删除按钮;
3605
+ // ·「撤销、恢复预设、清空都在那里」—— 是详情页上三个按钮的名字。
3606
+ // 名词语气+穷举,读起来像条款。现在只留**用户真正会问的那一个判断**
3607
+ //(「存这个会不会把我的昵称也换掉」),其余降级为问号里的 hover 全文。
3608
+ e('div', { className: 'soul-note soul-note-inline' },
3609
+ e('span', { className: 'soul-note-text' }, t('personas.hint')),
3610
+ e(SoulHint, { text: t('personas.hintDetail') })
3611
+ ),
3612
+ // 变更记录:一行「改动都能撤销」+ 问号里的全文。
3613
+ //
3614
+ // 这里**刻意不放「打开插件详情」按钮**:宿主 settings.section 槽位的
3615
+ // slotInject 是空串(见 dsh-cordis-client-runner 的槽位清单),设置页
3616
+ // 拿不到任何导航能力,点了也没法跳转 —— 一个点不动的按钮比没有更糟。
3617
+ e('div', { className: 'soul-note soul-note-inline' },
3618
+ e('span', { className: 'soul-note-text' }, t('history.entryHint')),
3619
+ e(SoulHint, { text: t('history.entryHintMore') })
3620
+ )
2296
3621
  ),
2297
3622
 
3623
+ // ——「工作区绑定」分组已从设置页撤下(0.8.0 发布前)——
3624
+ //
3625
+ // 原计划 0.8.1 交付「按目录绑人设」。但它要做的事(让目录决定人设)已经被
3626
+ // 0.9.0 的三级隔离(Global → Workspace → Session)整体接管,而这个分组是
3627
+ // 按「目录字符串」思考的 —— 工作区平铺卡片的交互形态与它完全不同,留下它
3628
+ // 只会让用户先学一套即将被替换的操作方式。
3629
+ //
3630
+ // **只摘渲染,逻辑层一行不动**:
3631
+ // · lib/bindings.mjs 的路径规范化 / realpath / 前缀匹配是硬资产,0.9.0 直接复用;
3632
+ // · 宿主侧的 cwd 解析、三条绑定路由、controller 方法全部保留 ——
3633
+ // 没有任何用户持有 soul-bindings.json(特性从未发布),解析恒为 null、
3634
+ // 行为与「没有绑定功能」完全一致,失败方向安全;
3635
+ // · SoulBindings 组件与 .soul-bindings-* 样式同样保留,0.9.0 重写 UI 时替换。
3636
+ //
3637
+ // 对应的行为用例 C29–C33 已随 UI 一并删除(它们测的是点击这个分组之后的
3638
+ // 交互,分组不在渲染树里就没有可断言的对象)。逻辑层仍由 verify-bindings
3639
+ // (47 项)与 verify-e2e-prompt 的 R1–R13 守着。
3640
+
2298
3641
  e(SoulAccordion, {
2299
3642
  id: 'soul-tools',
2300
3643
  title: t('group.tool'),
@@ -2392,6 +3735,11 @@ window.__ModuleLoader__.load({
2392
3735
  )
2393
3736
  ),
2394
3737
 
3738
+ // —— 变更记录:设置页不再留独立指引行 ——
3739
+ // 指引并入「人设预设」展开区的说明块(与 personas.hint 合并展示):
3740
+ // 独立一行在个性化区块里突兀,而折叠标题旁挂 hover 又说不到点上
3741
+ //(它解释的是展开后的东西)。这里不再渲染任何 history 状态,渲染成本为零。
3742
+
2395
3743
  e(SoulAccordion, {
2396
3744
  id: 'soul-prompt',
2397
3745
  title: t(promptIsDraft ? 'prompt.titleDraft' : 'prompt.title'),
@@ -2651,6 +3999,21 @@ window.__ModuleLoader__.load({
2651
3999
  }
2652
4000
  }, 'dsh-soul: global refresh polling')
2653
4001
 
4002
+ // —— 目录点选服务(0.8.1-d)——
4003
+ //
4004
+ // 用**动态** ctx.inject 拿 uiWorkspace,而不是把它写进上面的静态 inject 数组:
4005
+ // 静态数组里少一个服务,整个插件就不激活(设置页、人设卡、底栏一起消失)。
4006
+ // 目录选择器只是「能点一下就填路径」的便利,缺了它手输照常可用 ⇒ 必须可缺。
4007
+ // 服务到齐时写进模块级 directoryPicker,SoulBindings 的「选择目录…」才可见;
4008
+ // 调用失败(宿主 pickDirectory 抛错)时组件自行降级为手输,见 §7.5。
4009
+ ctx.inject(['uiWorkspace'], (scope) => {
4010
+ directoryPicker = scope.uiWorkspace
4011
+ scope.effect(() => () => {
4012
+ // 服务撤走 = 界面上不该再出现那个按钮(回调可能已被替换成新的 scope)
4013
+ if (directoryPicker === scope.uiWorkspace) directoryPicker = null
4014
+ }, 'dsh-soul: directory picker teardown')
4015
+ })
4016
+
2654
4017
  // 暗色适配说明:原生 <select> 的弹出选项列表由浏览器按 color-scheme 渲染。
2655
4018
  // 宿主主题呈现器会把 html 的 color-scheme 与 body[data-ds-dark-theme] 随主题
2656
4019
  // 投影到 document(见 dsh-client-ui-layout ThemePresenter),因此这里用纯 CSS
@@ -2856,6 +4219,24 @@ window.__ModuleLoader__.load({
2856
4219
  })
2857
4220
  }, SoulSettings))
2858
4221
 
4222
+ // —— 变更记录主入口:插件详情页(0.8.1 双入口)——
4223
+ // 槽位 plugins.detail.section(list/root):宿主在插件详情页自身内容下方
4224
+ // 为每个注册者渲染一个自定义区块;subject 形态与判据见 SoulHistorySection
4225
+ // 头注释(只服务 subject.pkg.name === 'dsh-soul' 的 bundle 详情页)。
4226
+ // 槽位不存在时 inject 静默不注册 ⇒ 由 verify-config 的源文本契约断言钉住槽位名。
4227
+ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
4228
+ name: 'plugins.detail.section',
4229
+ id: 'soul-detail-history',
4230
+ order: 50,
4231
+ locale: NS,
4232
+ inject: () => ({
4233
+ hooks: {
4234
+ soulController: controller.store
4235
+ },
4236
+ controller
4237
+ })
4238
+ }, SoulHistorySection))
4239
+
2859
4240
  // 初始配置的拉取已并入上面的全局轮询 —— 它的 refresh() 在注册时立即执行一次
2860
4241
  // (含 loadConfig + loadPersonas),这里再调一次就是同一次装配发两个相同请求。
2861
4242
  }