dsh-soul 0.7.2 → 0.9.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/README.md +39 -2
- package/README_EN.md +39 -2
- package/client/index.mjs +3497 -99
- package/index.mjs +1966 -121
- package/lib/bindings.mjs +430 -0
- package/lib/config.mjs +82 -1
- package/lib/history.mjs +605 -0
- package/lib/personas.mjs +59 -0
- package/lib/session-scope.mjs +432 -0
- package/lib/workspace-scope.mjs +348 -0
- package/package.json +9 -4
- package/scripts/verify-bindings.mjs +552 -0
- package/scripts/verify-client.mjs +2209 -16
- package/scripts/verify-config.mjs +1929 -24
- package/scripts/verify-e2e-prompt.mjs +1186 -24
- package/scripts/verify-history.mjs +782 -0
- package/scripts/verify-scope.mjs +328 -0
- package/scripts/verify-session-scope.mjs +435 -0
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.
|
|
51
|
+
const VERSION = '0.9.0'
|
|
52
52
|
const INITIAL = {
|
|
53
53
|
enabled: true,
|
|
54
54
|
nickname: '',
|
|
@@ -73,6 +73,46 @@ window.__ModuleLoader__.load({
|
|
|
73
73
|
activePersona: null,
|
|
74
74
|
// 预设列表请求失败的原因(成功时清空)。用于把「加载中」与「加载失败」区分开
|
|
75
75
|
personasError: null,
|
|
76
|
+
// 会话作用域解析结果:sessionId → { activePersona, source, workspaceTitle, ... }。
|
|
77
|
+
//
|
|
78
|
+
// 为什么放进**全局 store**而不是组件自己的 useState:
|
|
79
|
+
// · 会话「人设」tab 与 header 的人设标签是**两个组件**,都要看同一份数据;
|
|
80
|
+
// · 之前 header 标签只在 sessionId 变化时取一次 ⇒ 会话里切换人设后,
|
|
81
|
+
// 标签仍显示旧值(用户报的 bug);
|
|
82
|
+
// · 放 store 后,saveSessionScope 成功即写入,所有订阅者一起刷新 ——
|
|
83
|
+
// 不必再各自去轮询或监听。
|
|
84
|
+
//
|
|
85
|
+
// 值是普通对象(不是 Map):store 的更新走浅比较,Map 原地改不会触发。
|
|
86
|
+
sessionScopes: {},
|
|
87
|
+
// 变更记录(0.8.0):null = 还没拉过,[] = 拉过且确实没有记录。
|
|
88
|
+
// 两者必须分得开 —— 否则「没有记录」与「还没加载」在界面上是同一种空白。
|
|
89
|
+
history: null,
|
|
90
|
+
// 记录可能不完整的原因(读取窗口切掉、坏行、压实、写入失败),服务端给的原文
|
|
91
|
+
historyWarning: null,
|
|
92
|
+
historyError: null,
|
|
93
|
+
// 容量上限(服务端 HISTORY_MAX_ENTRIES)。由响应带回而不是写死在客户端:
|
|
94
|
+
// 两边各写一份迟早会不一致,而「最多保留 N 条」是一句会被人当真的承诺。
|
|
95
|
+
historyMax: null,
|
|
96
|
+
// 变更记录保留条数(配置字段,用户可改)。与 historyMax 不同:
|
|
97
|
+
// historyMax 是「当前配置承诺的上限」(响应带回),historyMaxEntries 是
|
|
98
|
+
// 设置表单里的那份值 —— 保存成功后两者应相等。
|
|
99
|
+
historyMaxEntries: 200,
|
|
100
|
+
// —— 工作区绑定(0.8.1)——
|
|
101
|
+
// 与 history 同款:null = 还没拉过,[] = 拉过且确实没有绑定。
|
|
102
|
+
// 「这个项目还没绑过」与「还没去看」是两件事,界面上不给它们同一种空白。
|
|
103
|
+
bindings: null,
|
|
104
|
+
// 失效引用(绑定的人设已不存在)与坏行丢弃数,都由服务端如实标注
|
|
105
|
+
bindingsInvalid: [],
|
|
106
|
+
bindingsDropped: null,
|
|
107
|
+
// 「记录可能不完整」的原因,不是失败(同 historyWarning)
|
|
108
|
+
bindingsWarning: null,
|
|
109
|
+
bindingsError: null,
|
|
110
|
+
// 服务端承诺的上限(MAX_BINDINGS / MAX_PATH_LENGTH),由响应带回,
|
|
111
|
+
// 不在客户端写死 —— 两边各写一份迟早会不一致
|
|
112
|
+
bindingsMax: null,
|
|
113
|
+
bindingsMaxPath: null,
|
|
114
|
+
// 活动会话的解析结果(只读状态:哪个目录现在用的是谁)
|
|
115
|
+
bindingsSessions: [],
|
|
76
116
|
saving: false,
|
|
77
117
|
error: null
|
|
78
118
|
}
|
|
@@ -127,6 +167,16 @@ window.__ModuleLoader__.load({
|
|
|
127
167
|
return ka.every((key) => shallowEqual(a[key], b[key]))
|
|
128
168
|
}
|
|
129
169
|
|
|
170
|
+
// 变更记录只在「追加一条」或「被清空」时变化,两者的**id 序列**都不一样 ——
|
|
171
|
+
// 所以逐条深比是白费力气(每条含 before/after/personas,深比一次的代价远超收益)。
|
|
172
|
+
// 比 id 序列足够,且顺带覆盖了「压实后旧条目被淘汰」这种变化。
|
|
173
|
+
const sameHistory = (a, b) => {
|
|
174
|
+
if (a === b) return true
|
|
175
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
176
|
+
if (a.length !== b.length) return false
|
|
177
|
+
return a.every((item, index) => item && b[index] && item.id === b[index].id)
|
|
178
|
+
}
|
|
179
|
+
|
|
130
180
|
function hostBase() {
|
|
131
181
|
const origin = globalThis.location?.origin
|
|
132
182
|
return origin !== void 0 && origin !== 'null' ? origin : 'http://dsh.internal'
|
|
@@ -398,6 +448,8 @@ window.__ModuleLoader__.load({
|
|
|
398
448
|
s.trailColor = config.trailColor || TRAIL_COLOR_FALLBACK
|
|
399
449
|
s.trailSpeed = config.trailSpeed || 'slow'
|
|
400
450
|
s.trailWidth = config.trailWidth || 'thin'
|
|
451
|
+
// 变更记录保留条数:服务端 migrate 已夹取到 50–500,这里只兜「缺席」
|
|
452
|
+
s.historyMaxEntries = Number.isFinite(config.historyMaxEntries) ? config.historyMaxEntries : 200
|
|
401
453
|
}
|
|
402
454
|
|
|
403
455
|
async loadConfig() {
|
|
@@ -500,8 +552,12 @@ window.__ModuleLoader__.load({
|
|
|
500
552
|
}
|
|
501
553
|
}
|
|
502
554
|
|
|
503
|
-
|
|
504
|
-
|
|
555
|
+
// draft(可选):设置页**还没保存**的草稿字段(0.9.0-r)。
|
|
556
|
+
// 不带它的话,用户改完表单直接点「存为预设」会存下旧配置(实测过的 bug)。
|
|
557
|
+
async savePersona(name, draft = null) {
|
|
558
|
+
const body = { name }
|
|
559
|
+
if (draft && typeof draft === 'object') body.draft = draft
|
|
560
|
+
return await this.postJSON('/api/soul/personas/save', body)
|
|
505
561
|
}
|
|
506
562
|
|
|
507
563
|
async usePersona(name) {
|
|
@@ -521,6 +577,235 @@ window.__ModuleLoader__.load({
|
|
|
521
577
|
return await this.postJSON('/api/soul/personas/delete', { name })
|
|
522
578
|
}
|
|
523
579
|
|
|
580
|
+
// —— 变更记录(0.8.0)——
|
|
581
|
+
async fetchHistory() {
|
|
582
|
+
return await this.postJSON('/api/soul/history', null, { method: 'GET' })
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 拉取记录并写入 store。
|
|
586
|
+
//
|
|
587
|
+
// 与 loadPersonas 同款:失败必须留下痕迹,否则界面永远停在「加载中」,
|
|
588
|
+
// 与「真的还没有记录」无从区分 —— 而「到底有没有记录」正是这一组的全部意义。
|
|
589
|
+
async loadHistory() {
|
|
590
|
+
if (this.disposed) return
|
|
591
|
+
try {
|
|
592
|
+
const payload = await this.fetchHistory()
|
|
593
|
+
this.store.update((s) => {
|
|
594
|
+
// 内容相同就保留旧引用,理由同 loadPersonas:每 2s 轮询都会解析出**新数组**,
|
|
595
|
+
// 直接赋值会让 store 的浅比较永远判「变了」,整页白重渲染。
|
|
596
|
+
const next = Array.isArray(payload.entries) ? payload.entries : []
|
|
597
|
+
s.history = sameHistory(s.history, next) ? s.history : next
|
|
598
|
+
// warning 是「记录可能不完整」,不是失败:不写进 historyError,
|
|
599
|
+
// 否则界面会把「只显示了最近 1000 条」报成红色错误。
|
|
600
|
+
s.historyWarning = payload.warning ? String(payload.warning) : null
|
|
601
|
+
s.historyMax = Number.isFinite(payload.max) ? payload.max : s.historyMax
|
|
602
|
+
s.historyError = null
|
|
603
|
+
})
|
|
604
|
+
} catch (err) {
|
|
605
|
+
this.store.update((s) => {
|
|
606
|
+
s.historyError = messageOf(err)
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 撤销:把配置退回到目标记录**发生之前**。
|
|
612
|
+
//
|
|
613
|
+
// 409(diverged)与 400(找不到 / 记录被压实淘汰)都是**预期内的拒绝**,
|
|
614
|
+
// 不是网络故障 —— 直接把服务端文案抛给调用方,由界面原样展示。
|
|
615
|
+
// 服务端已经把原因写得能读懂(「记录与当前配置对不上,可能有人手工改过配置文件」),
|
|
616
|
+
// 这里再包一层「撤销失败:...」只会把关键信息埋起来。
|
|
617
|
+
async rollbackHistory(id) {
|
|
618
|
+
const payload = await this.postJSON('/api/soul/history/rollback', { id })
|
|
619
|
+
if (!this.disposed) {
|
|
620
|
+
this.store.update((s) => {
|
|
621
|
+
this.applyConfig(s, payload.config)
|
|
622
|
+
s.error = null
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
return payload
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async restorePersona(id) {
|
|
629
|
+
const payload = await this.postJSON('/api/soul/history/restore-persona', { id })
|
|
630
|
+
if (!this.disposed) {
|
|
631
|
+
this.store.update((s) => {
|
|
632
|
+
this.applyConfig(s, payload.config)
|
|
633
|
+
s.error = null
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
return payload
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async clearHistory() {
|
|
640
|
+
return await this.postJSON('/api/soul/history/clear')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// —— 工作区绑定(0.8.1)——
|
|
644
|
+
async fetchBindings() {
|
|
645
|
+
return await this.postJSON('/api/soul/bindings', null, { method: 'GET' })
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 把服务端返回的绑定视图整块写进 store。
|
|
649
|
+
//
|
|
650
|
+
// 绑定的每个字段都是「服务端说了算」:失效引用由服务端按当前预设库算出,
|
|
651
|
+
// sessions 的生效人设由服务端走与真实装配同一条解析路径得出。客户端只负责
|
|
652
|
+
// 原样保留 —— 自己再算一遍只会有两种结果:算对了白算,算错了两个地方打架。
|
|
653
|
+
applyBindings(s, payload) {
|
|
654
|
+
// bindings 是**对象**(目录 → 人设名),不是数组 —— 服务端 bindingsView
|
|
655
|
+
// 直接透传 soul-bindings.json 的映射。上一版这里写的是 Array.isArray 兜底,
|
|
656
|
+
// 一旦为假就会把整张表静默吞成空:界面显示「还没有绑定任何目录」而文件里
|
|
657
|
+
// 其实有。故按对象判,且**缺字段时保留旧值**而不是清空。
|
|
658
|
+
const raw = payload.bindings
|
|
659
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) s.bindings = raw
|
|
660
|
+
else if (raw === null || Array.isArray(raw)) s.bindings = {}
|
|
661
|
+
s.bindingsInvalid = Array.isArray(payload.invalid) ? payload.invalid : []
|
|
662
|
+
s.bindingsDropped = Number.isFinite(payload.dropped) ? payload.dropped : 0
|
|
663
|
+
// warning 是「看到的不是全部」,不是失败,所以不写进 bindingsError
|
|
664
|
+
s.bindingsWarning = payload.warning ? String(payload.warning) : null
|
|
665
|
+
s.bindingsMax = Number.isFinite(payload.max) ? payload.max : s.bindingsMax
|
|
666
|
+
s.bindingsMaxPath = Number.isFinite(payload.maxPathLength)
|
|
667
|
+
? payload.maxPathLength
|
|
668
|
+
: s.bindingsMaxPath
|
|
669
|
+
s.bindingsSessions = Array.isArray(payload.sessions) ? payload.sessions : []
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 拉取绑定视图并写 store。
|
|
673
|
+
//
|
|
674
|
+
// 失败必须留下痕迹(同 loadHistory):界面停在「加载中」与「确实没有绑定」
|
|
675
|
+
// 长得一样,而这一组的全部意义就是回答「我这个项目现在是谁」。
|
|
676
|
+
async loadBindings() {
|
|
677
|
+
if (this.disposed) return
|
|
678
|
+
try {
|
|
679
|
+
const payload = await this.fetchBindings()
|
|
680
|
+
this.store.update((s) => {
|
|
681
|
+
this.applyBindings(s, payload)
|
|
682
|
+
s.bindingsError = null
|
|
683
|
+
})
|
|
684
|
+
} catch (err) {
|
|
685
|
+
this.store.update((s) => {
|
|
686
|
+
s.bindingsError = messageOf(err)
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 新增或改绑一个目录。写成功的结果里带回完整视图,直接铺进 store,
|
|
692
|
+
// 省掉一次「写完立刻再拉一遍」的往返。
|
|
693
|
+
async saveBinding(dir, persona) {
|
|
694
|
+
const payload = await this.postJSON('/api/soul/bindings', { dir, persona })
|
|
695
|
+
if (!this.disposed) {
|
|
696
|
+
this.store.update((s) => {
|
|
697
|
+
this.applyBindings(s, payload)
|
|
698
|
+
s.bindingsError = null
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
return payload
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async removeBinding(dir) {
|
|
705
|
+
const payload = await this.postJSON('/api/soul/bindings/delete', { dir })
|
|
706
|
+
if (!this.disposed) {
|
|
707
|
+
this.store.update((s) => {
|
|
708
|
+
this.applyBindings(s, payload)
|
|
709
|
+
s.bindingsError = null
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
return payload
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// —— 会话级覆盖(0.9.0-d)——
|
|
716
|
+
//
|
|
717
|
+
// 与绑定刻意分开:会话覆盖是**状态**(随会话生灭),绑定是**配置**。
|
|
718
|
+
// 因此这里不写 store 的全局字段,由调用组件自己持有结果 —— 一份覆盖只对
|
|
719
|
+
// 它所属的那个会话有意义,塞进全局 store 会让两个会话的 tab 互相串味。
|
|
720
|
+
async fetchSessionScope(sessionId) {
|
|
721
|
+
const query = `?sessionId=${encodeURIComponent(sessionId)}`
|
|
722
|
+
return await this.postJSON(`/api/soul/session-scope${query}`, null, { method: 'GET' })
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 当前会话的作用域(生效人设 + 来源层级)。与 fetchSessionScope 同源,
|
|
726
|
+
// 但走固定路径:这个端点给「输入框下方的标签」用 —— 它在会话外,
|
|
727
|
+
// 拿不到 sessionId,只能由宿主通过 scope:'session' 槽位的标准 props 传进来。
|
|
728
|
+
async fetchCurrentSessionScope(sessionId) {
|
|
729
|
+
const query = `?sessionId=${encodeURIComponent(sessionId)}`
|
|
730
|
+
return await this.postJSON(`/api/soul/session-scope/current${query}`, null, { method: 'GET' })
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// personaName:用户实际点的预设名。一并提交,服务端把它记进 sidecar ——
|
|
734
|
+
// 否则「当前生效的是哪个预设」只能靠字段反查,而反查在多个预设字段相同时不确定。
|
|
735
|
+
// 写 store:会话作用域变了 → 所有看它的人(header 标签、会话 tab)一起刷新。
|
|
736
|
+
//
|
|
737
|
+
// 不可变更新(新建对象)—— store 是浅比较,原地改同一个对象不会触发订阅者。
|
|
738
|
+
setSessionScope(sessionId, scope) {
|
|
739
|
+
if (this.disposed) return
|
|
740
|
+
this.store.update((s) => {
|
|
741
|
+
s.sessionScopes = { ...s.sessionScopes, [String(sessionId)]: scope }
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async saveSessionScope(sessionId, overrides, personaName = null) {
|
|
746
|
+
const body = { sessionId, overrides }
|
|
747
|
+
if (typeof personaName === 'string' && personaName !== '') body.personaName = personaName
|
|
748
|
+
const payload = await this.postJSON('/api/soul/session-scope', body)
|
|
749
|
+
// 服务端已经回了解析结果(activePersona / source / workspaceTitle)——
|
|
750
|
+
// 直接落进 store,header 标签立刻就是新值,不必再发一次请求。
|
|
751
|
+
if (payload && typeof payload === 'object') {
|
|
752
|
+
this.setSessionScope(sessionId, {
|
|
753
|
+
activePersona: typeof payload.activePersona === 'string' ? payload.activePersona : null,
|
|
754
|
+
recordedPersona: typeof payload.recordedPersona === 'string' ? payload.recordedPersona : null,
|
|
755
|
+
globalPersona: typeof payload.globalPersona === 'string' ? payload.globalPersona : null,
|
|
756
|
+
source: typeof payload.source === 'string' ? payload.source : null,
|
|
757
|
+
workspaceId: payload.workspaceId || null,
|
|
758
|
+
workspaceTitle: typeof payload.workspaceTitle === 'string' ? payload.workspaceTitle : null,
|
|
759
|
+
override: payload.override || null
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
return payload
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async clearSessionScope(sessionId) {
|
|
766
|
+
const payload = await this.postJSON('/api/soul/session-scope', { sessionId, clear: true })
|
|
767
|
+
if (payload && typeof payload === 'object') {
|
|
768
|
+
this.setSessionScope(sessionId, {
|
|
769
|
+
activePersona: typeof payload.activePersona === 'string' ? payload.activePersona : null,
|
|
770
|
+
recordedPersona: null,
|
|
771
|
+
globalPersona: typeof payload.globalPersona === 'string' ? payload.globalPersona : null,
|
|
772
|
+
source: typeof payload.source === 'string' ? payload.source : null,
|
|
773
|
+
workspaceId: payload.workspaceId || null,
|
|
774
|
+
workspaceTitle: typeof payload.workspaceTitle === 'string' ? payload.workspaceTitle : null,
|
|
775
|
+
override: null
|
|
776
|
+
})
|
|
777
|
+
}
|
|
778
|
+
return payload
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// —— 工作区人设(0.9.0-e)——
|
|
782
|
+
//
|
|
783
|
+
// 与绑定、会话覆盖都不同:工作区列表是**宿主**的,插件只能列与改,
|
|
784
|
+
// 不能增删(那是侧边栏的职责)。所以这里只有读 + 绑定/解绑。
|
|
785
|
+
async fetchWorkspaces() {
|
|
786
|
+
return await this.postJSON('/api/soul/workspaces', null, { method: 'GET' })
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async saveWorkspacePersona(workspaceId, persona) {
|
|
790
|
+
return await this.postJSON('/api/soul/workspaces', { workspaceId, persona })
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async removeWorkspacePersona(workspaceId) {
|
|
794
|
+
return await this.postJSON('/api/soul/workspaces/delete', { workspaceId })
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// —— 会话覆盖清理(0.9.0-f)——
|
|
798
|
+
//
|
|
799
|
+
// 默认 dry-run:不传 apply 就**绝不会删**。真删必须显式 apply:true ——
|
|
800
|
+
// 这个默认值站在"不删"那一侧,因为删除不可逆。
|
|
801
|
+
async scanSessionScopeGc() {
|
|
802
|
+
return await this.postJSON('/api/soul/session-scope/gc', {})
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async applySessionScopeGc() {
|
|
806
|
+
return await this.postJSON('/api/soul/session-scope/gc', { apply: true })
|
|
807
|
+
}
|
|
808
|
+
|
|
524
809
|
// 统一请求:body 为 null 时不携带请求体(GET 用)
|
|
525
810
|
async postJSON(path, body, overrides = {}) {
|
|
526
811
|
const url = new URL(path, hostBase())
|
|
@@ -605,12 +890,28 @@ window.__ModuleLoader__.load({
|
|
|
605
890
|
'.soul-accordion-icon{flex:0 0 18px;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:1;text-align:center}',
|
|
606
891
|
// 收起态只有触发器一个子元素 ⇒ 四角都要圆(展开态由上面的顶圆角规则负责)
|
|
607
892
|
'.soul-accordion:not(.soul-accordion-open) .soul-accordion-trigger{border-radius:calc(var(--dsw-radius-xl) - .5px)}',
|
|
893
|
+
// 折叠标题旁的问号提示已取消(说明改放展开区内的 .soul-note):
|
|
894
|
+
// head 层随之删除 —— 触发器恢复成 section 的直接子元素,无需 flex 包裹。
|
|
608
895
|
'.soul-accordion-body{padding:14px;border-top:.5px solid var(--dsw-alias-settings-card-stroke);background:transparent}',
|
|
609
896
|
'.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
897
|
'.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:14px}',
|
|
611
898
|
'.soul-toggle label{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
612
899
|
'.soul-buttons{display:flex;gap:8px;margin-top:2px}',
|
|
613
|
-
|
|
900
|
+
// 允许换行、不裁切。
|
|
901
|
+
//
|
|
902
|
+
// 为什么必须改容器(而不只是放宽 max-width):
|
|
903
|
+
// · white-space:nowrap 让整串强制一行 —— 输入框左侧空间有限,必然溢出;
|
|
904
|
+
// · overflow:hidden 把溢出部分**直接裁掉** —— 这就是「展示不全」的直接原因;
|
|
905
|
+
// · inline-flex + align-items:center 在多行时也对不齐。
|
|
906
|
+
//
|
|
907
|
+
// max-width:min(100%, 22em) 给个上限:太长的自定义预设名不至于把整条输入框撑开。
|
|
908
|
+
// overflow:hidden 是**扫光特效的裁切边界**,不能去掉:
|
|
909
|
+
// 扫光(::before)从 translateX(100%) 扫到 translateX(-100%),两端都会超出按钮。
|
|
910
|
+
//
|
|
911
|
+
// ⚠️ 0.9.0-g 为了让文字完整显示曾把它去掉 —— 结果特效溢出到按钮外面。
|
|
912
|
+
// 两者并不真正冲突:文字现在允许换行且有 max-width 上限,**不会溢出**内容盒;
|
|
913
|
+
// 真正溢出的只有扫光伪元素。所以 overflow:hidden 裁的是扫光,不裁文字。
|
|
914
|
+
'.soul-quick-toggle{position:relative;isolation:isolate;display:inline-flex;align-items:center;flex-wrap:wrap;gap:2px 6px;min-height:28px;max-width:min(100%,22em);overflow:hidden;padding:4px 10px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:normal}',
|
|
614
915
|
'.soul-quick-toggle:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
615
916
|
'.soul-quick-toggle[data-enabled="true"]{color:var(--dsw-alias-brand-primary)}',
|
|
616
917
|
'.soul-quick-toggle[data-enabled="true"]:hover{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-error-primary) 18%,transparent)}',
|
|
@@ -632,7 +933,46 @@ window.__ModuleLoader__.load({
|
|
|
632
933
|
'@keyframes soul-status-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-alias-state-success-primary) 35%,transparent);opacity:.75}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--dsw-alias-state-success-primary) 0%,transparent);opacity:1}}',
|
|
633
934
|
'@media (prefers-reduced-motion:reduce){.soul-quick-toggle[data-enabled="true"] .soul-quick-toggle-dot{animation:none}.soul-quick-toggle[data-enabled="true"]:hover::before,.soul-quick-toggle[data-enabled="false"]:hover::before{animation:none;transform:none}}',
|
|
634
935
|
// 快捷开关里追加的人设名(B):同样单行省略,避免把底栏撑宽
|
|
635
|
-
'.soul-quick-toggle-name{min-width:0;
|
|
936
|
+
'.soul-quick-toggle-name{min-width:0;opacity:.85;overflow-wrap:anywhere}',
|
|
937
|
+
// 来源层级标签(全局 / 工作区 / 本会话覆盖):次要色小字,与人设名分开 ——
|
|
938
|
+
// 它是注解不是主信息。max-width 限制它别把人设名挤掉。
|
|
939
|
+
'.soul-quick-toggle-scope{flex:none;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px;overflow-wrap:anywhere}',
|
|
940
|
+
// —— 会话 header 的人设标签(0.9.0-h)——
|
|
941
|
+
//
|
|
942
|
+
// 规格抄宿主的 AgentPresetLabel(同一位置的「创造模式」标签)——
|
|
943
|
+
// 但**底色 token 换掉了**:它用的 --dsw-alias-fill-tsp-secondary 在宿主主题里
|
|
944
|
+
// **没有定义**(只被那个包自己引用,var() 会静默回退),抄过来就是抄一个失效值。
|
|
945
|
+
// 这里用真实存在的 --dsw-alias-bg-module-platform。
|
|
946
|
+
// 22px 高、次要底色、圆角、小字、inline-flex。
|
|
947
|
+
// 宿主在窄容器时会把 header 标签隐藏(@container (width<=540px)),
|
|
948
|
+
// 我们跟着做 —— 空间不够时宁可不显示,也不要挤坏标题行。
|
|
949
|
+
'.soul-session-persona-label{display:inline-flex;align-items:center;gap:6px;max-width:min(100%,320px);height:22px;padding:0 8px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:22px;white-space:nowrap;overflow:hidden}',
|
|
950
|
+
// hero 区(新会话行)的落点:与邻居(工作区 chip / 创造模式)同一行右对齐。
|
|
951
|
+
// flex:none 防止被 chip 的 flex 收缩挤扁。
|
|
952
|
+
'.soul-session-persona-label[data-in-hero="true"]{flex:none}',
|
|
953
|
+
'.soul-session-persona-label-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
954
|
+
// 图标:与文字同色但略淡(图标是标识,文字是主信息)
|
|
955
|
+
'.soul-session-persona-label-icon{flex:none;opacity:.75}',
|
|
956
|
+
// 侧栏会话行 hover 面具徽标(0.9.0-ab):与行内其它 hover 按钮对齐
|
|
957
|
+
'.soul-sidebar-persona-badge{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;color:var(--dsw-alias-label-tertiary)}',
|
|
958
|
+
'.soul-sidebar-persona-badge-icon{display:block}',
|
|
959
|
+
// hover 信息卡里的「人设」一行(0.9.0-al):逐项对齐宿主同槽位住户
|
|
960
|
+
// (ui-schedule SessionScheduleHover 的 .task / .taskIcon / .taskName / .taskTiming):
|
|
961
|
+
// .task{display:flex;align-items:flex-start;gap:8px;min-width:0}
|
|
962
|
+
// .taskIcon{color:#cfd3d6;flex:none;height:16px;display:inline-flex}
|
|
963
|
+
// .taskName{color:#fff;font-size:12px;line-height:16px}
|
|
964
|
+
// .taskTiming{color:#cfd3d6;font-size:12px;line-height:16px}
|
|
965
|
+
//
|
|
966
|
+
// ⚠️ 这里必须用**不随主题翻转的 static 色**,不能用 --dsw-alias-* 主题 token:
|
|
967
|
+
// 宿主的 hover 卡片永远是深色的(它的 #fff / #cfd3d6 也是写死的),
|
|
968
|
+
// 而 alias token 会随主题翻转 —— 浅色主题下是深色值,照搬就成了深字压深底。
|
|
969
|
+
// #fff = bluish-00、#cfd3d6 = bluish-300(静态色板里的同值项,见 ui-theme)。
|
|
970
|
+
'.soul-hover-persona{display:flex;align-items:flex-start;gap:8px;min-width:0}',
|
|
971
|
+
'.soul-hover-persona-icon{flex:none;display:inline-flex;align-items:center;height:16px;color:var(--dsw-static-neutral-bluish-300)}',
|
|
972
|
+
'.soul-hover-persona-label{flex:none;color:var(--dsw-static-neutral-bluish-300);font-size:12px;line-height:16px}',
|
|
973
|
+
'.soul-hover-persona-name{min-width:0;color:var(--dsw-static-neutral-bluish-00);font-size:12px;line-height:16px;overflow-wrap:break-word}',
|
|
974
|
+
'.soul-session-persona-label-scope{flex:none;font-size:11px;opacity:.8}',
|
|
975
|
+
'@container (width<=540px){.soul-session-persona-label{display:none}}',
|
|
636
976
|
// 错误提示:颜色改用宿主真实存在的 state-error-primary(原先引用不存在的 token ⇒ 渲染成黑色)
|
|
637
977
|
'.soul-error{margin-top:8px;padding:10px 12px;background:var(--dsw-alias-interactive-bg-hover-danger,rgba(236,19,19,.05));border:.5px solid var(--dsw-alias-state-error-primary);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;width:100%;box-sizing:border-box}',
|
|
638
978
|
'.soul-hint{position:relative;display:inline-flex;align-items:center;margin-left:6px;color:var(--dsw-alias-label-secondary);cursor:help;vertical-align:middle}',
|
|
@@ -643,6 +983,15 @@ window.__ModuleLoader__.load({
|
|
|
643
983
|
'.soul-hint:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;border-radius:3px}',
|
|
644
984
|
'.soul-hint:focus-visible .soul-hint-tip{opacity:1;visibility:visible}',
|
|
645
985
|
'.soul-status{margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
986
|
+
// 说明块:一组「读到就够、不需要操作」的文字(预设收录范围 + 变更记录去哪了)。
|
|
987
|
+
// 与 .soul-status 同排印,但允许多行分行 —— 多条说明挤成一段会像一句话里
|
|
988
|
+
// 混了两件事。行距靠 line-height 自然撑开,不上边框(不是引用也不是分隔)。
|
|
989
|
+
'.soul-note{margin-top:10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
990
|
+
'.soul-note > div + div{margin-top:2px}',
|
|
991
|
+
// 一句话 + 一个问号:正文不再换行成段,问号挂在句子末尾(0.8.1 文案瘦身)。
|
|
992
|
+
// align-items:baseline 让问号与 12px 文字同基线 —— 用 center 会让它看起来浮起来。
|
|
993
|
+
'.soul-note-inline{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}',
|
|
994
|
+
'.soul-note-text{min-width:0}',
|
|
646
995
|
'.soul-dirty{color:var(--dsw-alias-state-warn-primary)}',
|
|
647
996
|
// —— 人设预设:竖版卡牌 + 堆叠轮播 ——
|
|
648
997
|
// 方案 B:插画是卡面主视觉(方形满铺),名称与摘要压在图上由渐变承接,
|
|
@@ -740,6 +1089,8 @@ window.__ModuleLoader__.load({
|
|
|
740
1089
|
'.soul-persona-art-title{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:2px;min-width:0}',
|
|
741
1090
|
'.soul-persona-name{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-static-neutral-bluish-00,#fff);font-size:14px;font-weight:600;line-height:20px}',
|
|
742
1091
|
'.soul-persona-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 1px 6px rgba(0,0,0,.5)}',
|
|
1092
|
+
// 作用域标注(全局):小一号、半透明,跟在预设名后面 —— 注解而非主信息。
|
|
1093
|
+
'.soul-persona-scope-tag{flex:none;padding:0 5px;border-radius:999px;background:rgba(255,255,255,.18);color:inherit;font-size:9px;font-weight:600;line-height:14px;letter-spacing:.2px;opacity:.75}',
|
|
743
1094
|
'.soul-persona-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-static-neutral-bluish-100,#d3dbf5);font-size:11px;line-height:16px;text-shadow:0 1px 6px rgba(0,0,0,.5)}',
|
|
744
1095
|
// 内置徽标:图上用半透明玻璃底(白底徽标在深色图上会太跳)
|
|
745
1096
|
'.soul-persona-badge{position:absolute;top:10px;left:10px;flex:none;padding:2px 7px;border-radius:999px;border:1px solid rgba(255,255,255,.34);background:rgba(255,255,255,.2);color:var(--dsw-static-neutral-bluish-00,#fff);font-size:10px;font-weight:600;line-height:14px;letter-spacing:.3px;backdrop-filter:blur(8px)}',
|
|
@@ -791,7 +1142,12 @@ window.__ModuleLoader__.load({
|
|
|
791
1142
|
// 类名保留是为了兼容既有断言与「空占位不能是可点控件」这条历史契约。
|
|
792
1143
|
'.soul-persona-slot{display:none}',
|
|
793
1144
|
// 徽标:对齐宿主的 rowTag 规格(.5px 描边、radius-xs、11px/16px)
|
|
794
|
-
|
|
1145
|
+
// ⚠️ 这条是**卡背头**的徽标样式(描边式、inline、灰字),必须限定在 .soul-persona-back-head 内。
|
|
1146
|
+
// 若不限定,它会覆盖卡面那条(absolute、玻璃底、白字)—— 而它自己又不写 position,
|
|
1147
|
+
// 于是卡面徽标变成「白字玻璃底 + absolute 定位 + 描边式字号」的混合体,在插画上漂。
|
|
1148
|
+
// 会话「人设」tab 的卡面徽标曾因此漂移:它没有 .soul-persona-back-head 包裹,
|
|
1149
|
+
// 上面那条 position:static 的重置不生效。
|
|
1150
|
+
'.soul-persona-back-head .soul-persona-badge{border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xs);color:var(--dsw-alias-label-secondary);flex:none;padding:0 6px;font-size:11px;line-height:16px;white-space:nowrap}',
|
|
795
1151
|
// ——「存为新预设」命名弹层(方案 A)——
|
|
796
1152
|
// 遮罩用 fixed 全屏 + 半透明;层级要低于 toast(toast 是 10000),
|
|
797
1153
|
// 因为保存成功后 toast 要能盖在弹层之上被看到。
|
|
@@ -802,6 +1158,17 @@ window.__ModuleLoader__.load({
|
|
|
802
1158
|
// 「将保存 / 不含」两行说明:这是消除「不知道存了哪些值」的关键,用弱化样式但仍可读
|
|
803
1159
|
'.soul-modal-note{display:flex;flex-direction:column;gap:4px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
804
1160
|
'.soul-modal-note-excl{color:var(--dsw-alias-label-tertiary)}',
|
|
1161
|
+
// 「保存后立即应用」复选框(0.9.0-t)
|
|
1162
|
+
'.soul-modal-check{display:flex;align-items:flex-start;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
1163
|
+
'.soul-modal-check input{flex:none;margin-top:3px;width:14px;height:14px;cursor:pointer}',
|
|
1164
|
+
'.soul-modal-check-hint{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}',
|
|
1165
|
+
// 「更新到预设」列表(0.9.0-s):每行一个可选预设名。
|
|
1166
|
+
'.soul-update-list{display:flex;flex-direction:column;gap:4px;max-height:240px;overflow:auto}',
|
|
1167
|
+
'.soul-update-item{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer}',
|
|
1168
|
+
'.soul-update-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-brand-primary)}',
|
|
1169
|
+
'.soul-update-item:disabled{cursor:wait;opacity:.6}',
|
|
1170
|
+
'.soul-update-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto}',
|
|
1171
|
+
'.soul-update-badge{flex:none;color:var(--dsw-alias-brand-primary);font-size:11px;line-height:16px}',
|
|
805
1172
|
'.soul-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
|
|
806
1173
|
// 二次确认态:描边 → 实心危险色,**并放宽按钮**。
|
|
807
1174
|
// 放宽是必须的:普通态按「删除」两字定宽 43px,而「确认删除?」是五个字,
|
|
@@ -869,6 +1236,283 @@ window.__ModuleLoader__.load({
|
|
|
869
1236
|
'.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
1237
|
'.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
|
|
871
1238
|
'.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
1239
|
+
// —— 变更记录(0.8.0)——
|
|
1240
|
+
// 顶部状态行 + 右侧「清空记录」:与 .soul-drift 同款(space-between),
|
|
1241
|
+
// 因为它也是「一句话 + 一个行动」的形态。
|
|
1242
|
+
'.soul-history-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:6px}',
|
|
1243
|
+
'.soul-history-count{margin-top:0}',
|
|
1244
|
+
// —— 插件详情页外壳(0.8.1 双入口):对齐宿主 detailSection 排版 ——
|
|
1245
|
+
// 宿主 .detailSection = flex column / gap 12px;.sectionHead = baseline 对齐;
|
|
1246
|
+
// .sectionTitle = 14px/500/20px;.sectionCount = 12px 次要色(fO69Vq_ 前缀)。
|
|
1247
|
+
'.soul-detail-history{display:flex;flex-direction:column;gap:12px}',
|
|
1248
|
+
'.soul-detail-history-head{display:flex;align-items:baseline;gap:10px}',
|
|
1249
|
+
'.soul-detail-history-title{margin:0;font-size:14px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
1250
|
+
'.soul-detail-history-count{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1251
|
+
// —— 会话「人设」tab(0.9.0-d)——
|
|
1252
|
+
// 它是 conversation.view 的一种,宿主把它画在对话区里,所以要自带内边距与
|
|
1253
|
+
// 最大宽度(不像设置页有外层容器)。参数抄宿主的 viewArea 排版:
|
|
1254
|
+
// 内容左对齐、与对话区同宽的上限,宽屏下不铺满整行。
|
|
1255
|
+
// 与宿主 conversation 输入框同宽:直接读 --dsh-chat-content-width
|
|
1256
|
+
// (宿主 body 规则里定义为 clamp(680px, 64%, 920px))。宿主调宽窄、tab 跟着变,
|
|
1257
|
+
// 比硬编码 720px 更稳;变量缺失时回退 720px(与上一版一致)。
|
|
1258
|
+
'.soul-session-persona{display:flex;flex-direction:column;gap:16px;max-width:var(--dsh-chat-content-width,720px);width:100%;margin:0 auto;padding:20px 4px;box-sizing:border-box;color:var(--dsw-alias-label-primary)}',
|
|
1259
|
+
'.soul-session-head{display:flex;align-items:baseline;gap:8px}',
|
|
1260
|
+
'.soul-session-title{margin:0;font-size:14px;font-weight:500;line-height:20px}',
|
|
1261
|
+
// 容器内间距全部交给 gap:16px 统一 —— .soul-field 自带的 margin-bottom:14px
|
|
1262
|
+
// 会让「临时改用」与下方卡牌的间距比上方标题的 16px 小,视觉不齐(用户反馈)。
|
|
1263
|
+
// ⚠️ 0.9.0-af 补全:光清 .soul-field 不够 —— 前置排查发现容器内还有多处
|
|
1264
|
+
// 自带纵向 margin:.soul-field-label(8px bottom)、.soul-status(top)、
|
|
1265
|
+
// .soul-session-split(top:2px)、.soul-session-clear-row(top)、
|
|
1266
|
+
// .soul-session-detail-actions(top)、.soul-session-scope-hint(top)。
|
|
1267
|
+
// 每漏一处,三段间距就有一段不齐(用户连续报了两次)。
|
|
1268
|
+
// 通配清零容器内所有子元素的纵向 margin —— 间距语义只归 gap 一处管。
|
|
1269
|
+
// 面板内部的 margin(detail-actions/scope-hint 的 margin-top)不受影响:
|
|
1270
|
+
// 它们在 .soul-session-detail 里,不是本容器的直接子元素。
|
|
1271
|
+
'.soul-session-persona > *{margin-top:0;margin-bottom:0}',
|
|
1272
|
+
// 「有覆盖」标记:一眼看出这个会话与外面的设置不同,避免用户以为改的是全局
|
|
1273
|
+
'.soul-session-badge{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}',
|
|
1274
|
+
// 当前生效卡:与设置页 .soul-bindings-current 同款观感(描边 + 浅底)
|
|
1275
|
+
'.soul-session-current{display:flex;flex-direction:column;gap:2px;padding:10px 12px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1)}',
|
|
1276
|
+
// 名称加粗(0.9.0-aa):这是 tab 里最重要的信息,500 不够突出
|
|
1277
|
+
'.soul-session-current-name{font-size:14px;line-height:22px;font-weight:700}',
|
|
1278
|
+
// 特质突出点(非默认维度)小字:与名称同行,弱于名称
|
|
1279
|
+
'.soul-session-current-traits{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:22px;font-weight:400}',
|
|
1280
|
+
'.soul-session-current-source{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1281
|
+
// 人设胶囊:可点即选,选中的用主色实心(与设置页的「使用中」同一套色彩语义)
|
|
1282
|
+
'.soul-session-chips{display:flex;flex-wrap:wrap;gap:8px}',
|
|
1283
|
+
// 与「使用中」按钮(.soul-persona-action,36px / radius-md)并排,
|
|
1284
|
+
// 高度与圆角必须一致 —— 否则同一行里一高一低。
|
|
1285
|
+
// .soul-session-chip 现在只剩「取消本会话的临时人设」这一个使用点
|
|
1286
|
+
// (0.9.0-d 移除了旧胶囊选择器),直接对齐规格;配色保持描边式(次要动作)。
|
|
1287
|
+
'.soul-session-chip{box-sizing:border-box;display:inline-flex;align-items:center;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-md,12px);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
|
|
1288
|
+
'.soul-session-chip:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
|
|
1289
|
+
'.soul-session-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
1290
|
+
'.soul-session-chip:disabled{opacity:.5;cursor:default}',
|
|
1291
|
+
'.soul-session-chip-active{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}',
|
|
1292
|
+
// 「取消本会话的临时人设」用次要色:它可逆,不是破坏性动作
|
|
1293
|
+
'.soul-session-chip-clear{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
|
|
1294
|
+
'.soul-session-clear-row{display:flex;margin-top:10px}',
|
|
1295
|
+
// —— 会话「人设」:左卡牌网格 + 右详情(0.9.0-d 改版)——
|
|
1296
|
+
//
|
|
1297
|
+
// 与设置页的人设卡牌**共用同一套视觉语言**(圆角、描边、浅底、插画 + 压图标题),
|
|
1298
|
+
// 但布局从「叠层轮播 + 3D 翻面」改成「左侧网格选中 + 右侧详情面板」:
|
|
1299
|
+
// 会话面板横向空间窄,左侧是**叠层轮转**而非平铺:中心大卡 + 两侧各露边一张。
|
|
1300
|
+
// 比例:左栏 260px(正好容下 260px 的叠层)+ 右栏自适应;放不下时整体换行。
|
|
1301
|
+
'.soul-session-split{display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}',
|
|
1302
|
+
// 叠层容器:卡片绝对定位到同一格,靠内联 transform 偏移(与设置页同构)。
|
|
1303
|
+
// overflow:hidden 让两侧露边卡在容器边缘被自然裁掉,观感与设置页一致。
|
|
1304
|
+
// 叠层容器:**1:1**(高度 = 卡宽 210px)。
|
|
1305
|
+
//
|
|
1306
|
+
// 为什么是 1:1:插画资源全是 512×512 正方形,而容器若是竖长的(比如 210×380),
|
|
1307
|
+
// 配合 object-fit:cover 就会**上下裁掉约 44%**,只剩中间一条 —— 看起来像「插画不够大」。
|
|
1308
|
+
// 1:1 容器让插画完整显示,且比原来看到的那条中间带大得多。
|
|
1309
|
+
// 轮播容器:叠层卡 + 下方导航(同一列,纵向)
|
|
1310
|
+
'.soul-session-carousel{flex:0 0 300px;min-width:300px;display:flex;flex-direction:column;align-items:center;gap:10px}',
|
|
1311
|
+
// 0.9.0-ag:soul-field 是普通块容器 —— split 与 detail 垂直堆叠(detail 被
|
|
1312
|
+
// 挤到卡牌下方),这才是「三段间距不一致」的真身。改 flex 行布局后
|
|
1313
|
+
// detail 回到卡牌右侧,split(338) 与 detail(338) 等高。
|
|
1314
|
+
'.soul-field.soul-persona-field{display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}',
|
|
1315
|
+
'.soul-session-grid{position:relative;width:100%;height:300px;overflow:hidden}',
|
|
1316
|
+
// 单张卡:结构抄 .soul-persona-card 的正面(去掉 3D 翻面 —— 翻面由右栏详情承担)。
|
|
1317
|
+
// ⚠️ 这里**不得**写任何 transform:叠层偏移由内联 style 持有(translateX/scale);
|
|
1318
|
+
// 若 CSS 也给 transform,两者会互相覆盖。
|
|
1319
|
+
// 插画铺满整张卡:art 绝对定位 inset:0,卡本身只提供定位上下文与圆角裁切。
|
|
1320
|
+
'.soul-session-card{position:absolute;top:0;bottom:0;left:0;right:0;width:300px;margin:0 auto;display:block;overflow:hidden;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-settings-card-stroke);background:var(--dsw-alias-settings-card-fill);box-shadow:var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.08));box-sizing:border-box;font-size:12px;line-height:18px;transition:transform .3s cubic-bezier(.22,.61,.36,1),opacity .3s ease;cursor:pointer}',
|
|
1321
|
+
'.soul-session-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
|
|
1322
|
+
// 生效中的那张:**不加描边**(0.9.0-u)。
|
|
1323
|
+
//
|
|
1324
|
+
// 历史:曾用主色描边 + 1px 光晕表达「生效」,但主色在浅色主题下解析为近黑
|
|
1325
|
+
// (#0f1115),压在插画上是一圈很重的黑框 —— 用户反馈太丑。
|
|
1326
|
+
// 生效标识已有**左下角的 ★ 角标**(半透明深底圆 + 白字,不依赖遮罩),
|
|
1327
|
+
// 描边是重复信号,去掉后 ★ 成为唯一依据,观感干净得多。
|
|
1328
|
+
// 若将来要恢复描边,注意别与 hover 的 translateY 合成同一个 transform。
|
|
1329
|
+
'.soul-session-card[data-effective="true"]{box-shadow:var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.08))}',
|
|
1330
|
+
// 选中的那张:**只抬高一档 + 投影加深**,不再加描边。
|
|
1331
|
+
//
|
|
1332
|
+
// 为什么去掉那圈 1px 光晕:它与上面「生效」的描边是同一个主色,浅色主题下两者都是
|
|
1333
|
+
// 近黑 ⇒ 生效卡与选中卡叠在一起时是「双层黑框」,完全分不出哪个是「正在生效」、
|
|
1334
|
+
// 哪个是「正在浏览」。把描边独占给「生效」,选中只留位移与投影,两个状态就分开了。
|
|
1335
|
+
'.soul-session-card[data-selected="true"]{transform:translateY(-1px);box-shadow:0 4px 14px rgba(0,0,0,.18),var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.08))}',
|
|
1336
|
+
// 生效 **且** 选中(最常见:中心卡正是生效的那张)⇒ 投影取两者中更重的一档。
|
|
1337
|
+
'.soul-session-card[data-selected="true"][data-effective="true"]{transform:translateY(-1px);box-shadow:0 4px 14px rgba(0,0,0,.18)}',
|
|
1338
|
+
// 插画区:**绝对定位铺满整张卡**。
|
|
1339
|
+
// 原来它在文档流里(1:1 的画面 + 下方文字区),现在没有文字区了 ⇒ 直接铺满,
|
|
1340
|
+
// 画面大得多。aspect-ratio 不再需要(高度由容器决定)。
|
|
1341
|
+
'.soul-session-card .soul-persona-art{position:absolute;inset:0;width:100%;height:100%;aspect-ratio:auto}',
|
|
1342
|
+
// 生效标记 ★:放左下角(右上角被「i / n」占了,左上角被内置徽标占了)。
|
|
1343
|
+
// 半透明深底 + 白字 —— 不依赖遮罩,在任何底图上都读得清。
|
|
1344
|
+
'.soul-session-card-star{position:absolute;left:10px;bottom:10px;width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(10,14,35,.55);color:var(--dsw-static-neutral-bluish-00,#fff);font-size:13px;line-height:1;backdrop-filter:blur(6px)}',
|
|
1345
|
+
// 右栏详情:复用设置页卡背的元素(.soul-persona-dims / .soul-persona-fulltext),
|
|
1346
|
+
// 这里只补容器与滚动。高度上限避免长文把整个面板撑得比卡牌栏高太多。
|
|
1347
|
+
// 详情面板:**固定高度**(0.9.0-z)。
|
|
1348
|
+
//
|
|
1349
|
+
// 为什么不用 max-height:那会让面板高度跟着内容走 —— 每个人设的
|
|
1350
|
+
// 自定义指令长度不同,切换时面板一高一低地跳,左侧轮播也跟着晃。
|
|
1351
|
+
// 固定 height 后切换任何预设面板都不变,只有内容区滚动。
|
|
1352
|
+
//
|
|
1353
|
+
// 340px = 原上限值,实测能容纳六维度 + 约 12 行指令;更长的指令在内部滚动。
|
|
1354
|
+
'.soul-session-detail{flex:1 1 220px;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:12px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill);height:338px;box-sizing:border-box;overflow:hidden}',
|
|
1355
|
+
'.soul-session-detail-empty{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1356
|
+
'.soul-session-detail-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
1357
|
+
'.soul-session-detail-name{font-size:14px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
1358
|
+
'.soul-session-detail-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:2px}',
|
|
1359
|
+
'.soul-session-detail-actions .soul-persona-action{flex:1 1 auto}',
|
|
1360
|
+
// 作用范围说明(0.9.0-z):小字次要色,一行即可
|
|
1361
|
+
'.soul-session-scope-hint{margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
|
|
1362
|
+
// 覆盖里的字段名:等宽,便于与配置键对上
|
|
1363
|
+
'.soul-session-fields{display:flex;flex-wrap:wrap;gap:6px}',
|
|
1364
|
+
'.soul-session-field{padding:1px 6px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:18px}',
|
|
1365
|
+
// —— 工作区人设平铺卡片(0.9.0-e)——
|
|
1366
|
+
// 竖排卡片而非下拉:工作区通常 3–10 个,每张要同时表达「名字 + 路径 + 绑定态」。
|
|
1367
|
+
'.soul-ws-list{display:flex;flex-direction:column;gap:8px}',
|
|
1368
|
+
'.soul-ws-card{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:10px 12px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1)}',
|
|
1369
|
+
// 已绑定:左侧一道主色竖条(与「命中当前目录」同一套视觉语义)
|
|
1370
|
+
'.soul-ws-card-bound{box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary)}',
|
|
1371
|
+
'.soul-ws-main{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}',
|
|
1372
|
+
'.soul-ws-title{font-size:13px;line-height:20px;font-weight:500;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
1373
|
+
'.soul-ws-path{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:18px}',
|
|
1374
|
+
'.soul-ws-invalid{display:flex;flex-wrap:wrap;gap:6px;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}',
|
|
1375
|
+
'.soul-ws-actions{display:flex;align-items:center;gap:8px;flex:none}',
|
|
1376
|
+
'.soul-ws-persona{color:var(--dsw-alias-brand-primary);font-size:12px;line-height:18px}',
|
|
1377
|
+
'.soul-ws-unbound{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}',
|
|
1378
|
+
// 工作区卡片上的文字按钮(0.9.0-ao):规格照抄宿主设置-模型页的
|
|
1379
|
+
// 「编辑 / 删除」(ui-settings-models 的 .secondaryButton / .dangerButton)。
|
|
1380
|
+
//
|
|
1381
|
+
// 编辑(secondaryButton):描边 + label-primary 文字 + hover 浅灰底
|
|
1382
|
+
// {border:.5px solid border-l3;color:label-primary;background:0 0}
|
|
1383
|
+
// {border-radius:sm;height:28px;padding:0 10px;font-size:12px;line-height:18px}
|
|
1384
|
+
// :hover{background:interactive-bg-hover-solid}
|
|
1385
|
+
// 删除(dangerButton):**无描边** + 红字 + hover 浅红底
|
|
1386
|
+
// {color:state-error-primary;background:0 0;border:none}
|
|
1387
|
+
// :hover{background:interactive-bg-hover-danger}
|
|
1388
|
+
'.soul-ws-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);height:28px;padding:0 10px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
|
|
1389
|
+
'.soul-ws-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}',
|
|
1390
|
+
'.soul-ws-btn:disabled{opacity:.4;cursor:default}',
|
|
1391
|
+
'.soul-ws-btn:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline:none}',
|
|
1392
|
+
'.soul-ws-btn-danger{border:none;color:var(--dsw-alias-state-error-primary)}',
|
|
1393
|
+
'.soul-ws-btn-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}',
|
|
1394
|
+
// 待确认:文字已经变成「确认解绑…」,再叠一层浅红底让它更醒目
|
|
1395
|
+
'.soul-ws-btn-armed{background:var(--dsw-alias-interactive-bg-hover-danger)}',
|
|
1396
|
+
'.soul-ws-action{box-sizing:border-box;display:inline-flex;align-items:center;height:26px;padding:0 10px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
|
|
1397
|
+
// ⚠️ hover **只改边框,不改文字色**(0.9.0-am)。
|
|
1398
|
+
// 原先这里还有 color:brand-primary —— 主色按钮(深底白字)hover 时文字被压成
|
|
1399
|
+
// 近黑色,黑底黑字=整块全黑;同时它也压掉了 .soul-ws-action-armed 的红字。
|
|
1400
|
+
'.soul-ws-action:hover{border-color:var(--dsw-alias-brand-primary)}',
|
|
1401
|
+
'.soul-ws-action:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
1402
|
+
'.soul-ws-action:disabled{opacity:.5;cursor:default}',
|
|
1403
|
+
// 「绑定人设」是这一页的主操作:实心主色
|
|
1404
|
+
|
|
1405
|
+
// 解绑 armed:危险色(与删除预设同一套)
|
|
1406
|
+
'.soul-ws-action-armed{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}',
|
|
1407
|
+
// 内联展开的人设胶囊:占满整行,与卡片内容左对齐
|
|
1408
|
+
'.soul-ws-picker{flex-basis:100%;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:8px;border-top:.5px solid var(--dsw-alias-settings-card-stroke)}',
|
|
1409
|
+
'.soul-ws-picker-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1410
|
+
// 会话清理的按钮行:与设置页的 .soul-buttons **分开命名**(那个 class 语义是
|
|
1411
|
+
// 「保存按钮区」,复用它会让"保存设置在哪里"这类断言抓到错误的块 —— 实测抓到过)。
|
|
1412
|
+
'.soul-sessions-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px}',
|
|
1413
|
+
// 保留条数(0.8.1 从设置页迁来):一行「标签 + 下拉 + 提示」。
|
|
1414
|
+
// 下拉不撑满整行 —— 它是容量参数,不是表单主字段,铺满会显得比记录本身还重。
|
|
1415
|
+
'.soul-history-retention{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
1416
|
+
'.soul-history-retention label{margin:0;flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1417
|
+
'.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}',
|
|
1418
|
+
'.soul-history-retention select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
1419
|
+
'.soul-history-retention select:disabled{opacity:.6}',
|
|
1420
|
+
// 下拉右侧的问号提示沿用设置页的 SoulHint;这里只压掉它的上边距以对齐这一行
|
|
1421
|
+
'.soul-history-retention .soul-hint{margin-top:0}',
|
|
1422
|
+
// —— 设置页指引(0.8.1 双入口)——
|
|
1423
|
+
// 列表整体迁去插件详情页后,设置页不再有指引行;去向说明并入「人设预设」
|
|
1424
|
+
// 展开区的 .soul-note(见下方 .soul-note 注释),不再单独占一行。
|
|
1425
|
+
// 危险色描边文字钮(规格抄 .soul-persona-danger):清空记录不可逆,
|
|
1426
|
+
// 是这一组唯一的破坏性动作。撤销按钮不用危险色 —— 它可以再撤销一次改回来。
|
|
1427
|
+
'.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}',
|
|
1428
|
+
'.soul-history-clear:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(236,19,19,.05))}',
|
|
1429
|
+
'.soul-history-clear:focus-visible{outline:2px solid var(--dsw-alias-state-error-primary);outline-offset:1px}',
|
|
1430
|
+
// armed:实心危险色 + 文案变成「确认清空?」(样式抄 .soul-persona-danger-armed)
|
|
1431
|
+
'.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)}',
|
|
1432
|
+
'.soul-history-warning{margin-top:6px}',
|
|
1433
|
+
'.soul-history-list{display:flex;flex-direction:column;gap:6px;margin:10px 0 0 0;padding:0;list-style:none}',
|
|
1434
|
+
'.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}',
|
|
1435
|
+
// 头部是「主体按钮 + 操作按钮」两段:主体撑满左侧,操作固定在右,
|
|
1436
|
+
// 因此主体按钮必须 min-width:0 才能让里面的 ellipsis 生效。
|
|
1437
|
+
'.soul-history-item-head{display:flex;align-items:center;gap:8px;padding-right:8px}',
|
|
1438
|
+
'.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}',
|
|
1439
|
+
'.soul-history-item-main:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
1440
|
+
'.soul-history-item-main:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
|
|
1441
|
+
// 时间与来源不参与收缩(它们短且固定),字段名列表吃掉剩余宽度并省略。
|
|
1442
|
+
'.soul-history-time{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}',
|
|
1443
|
+
'.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}',
|
|
1444
|
+
'.soul-history-fields{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
|
|
1445
|
+
'.soul-history-item-actions{display:flex;align-items:center;gap:6px;flex:none}',
|
|
1446
|
+
'.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}',
|
|
1447
|
+
'.soul-history-action:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
1448
|
+
'.soul-history-action:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
1449
|
+
'.soul-history-action-armed{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}',
|
|
1450
|
+
'.soul-history-detail{padding:0 10px 10px 10px;border-top:.5px solid var(--dsw-alias-settings-card-stroke)}',
|
|
1451
|
+
'.soul-history-diff{display:flex;flex-direction:column;gap:10px;padding-top:8px}',
|
|
1452
|
+
// 「展开即全文」:每行竖排 —— 字段名、原值块、新值块依次铺开。
|
|
1453
|
+
// 曾经是 88px/1fr/14px/1fr 的四列网格 + 单行省略,但自定义指令几百字时
|
|
1454
|
+
// 一行省略号等于没给;竖排让长文本换行铺开,短字段也不吃亏。
|
|
1455
|
+
'.soul-history-diff-row{display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px}',
|
|
1456
|
+
'.soul-history-diff-field{color:var(--dsw-alias-label-tertiary);word-break:break-word}',
|
|
1457
|
+
// pre-wrap:介绍 / 自定义指令里的换行是内容的一部分,压成一行会看不出改了什么。
|
|
1458
|
+
'.soul-history-diff-cell{display:flex;flex-direction:column;gap:2px}',
|
|
1459
|
+
'.soul-history-diff-tag{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
|
|
1460
|
+
'.soul-history-diff-value{min-width:0;white-space:pre-wrap;word-break:break-word}',
|
|
1461
|
+
// 原值块垫底色区分「这是被替换掉的」,新值保持正文色;省略号阶段二值同权重,全文阶段必须能一眼分清。
|
|
1462
|
+
'.soul-history-diff-cell:first-of-type .soul-history-diff-value{color:var(--dsw-alias-label-tertiary)}',
|
|
1463
|
+
'.soul-history-diff-value{color:var(--dsw-alias-label-primary)}',
|
|
1464
|
+
'.soul-history-diff-persona{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1465
|
+
// 分页器(0.9.0-ai):左按钮 / 页码 / 右按钮,纵向居中对齐
|
|
1466
|
+
'.soul-history-pager{align-self:flex-start;display:flex;align-items:center;gap:8px;margin-top:8px}',
|
|
1467
|
+
'.soul-history-pageinfo{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1468
|
+
// —— 设置页「工作区绑定」分组(0.8.1-d)——
|
|
1469
|
+
// 外层容器只负责块间距:三段是 .soul-field,彼此之间要留出与其它分组一致的空。
|
|
1470
|
+
'.soul-bindings{display:flex;flex-direction:column;gap:14px}',
|
|
1471
|
+
// 尺寸全部抄本文件既有的宿主对齐规格:输入框复用 .soul-field 的控件规则,
|
|
1472
|
+
// 小按钮复用 .soul-history-action(24px 高 / 描边 / brand 文字),
|
|
1473
|
+
// 危险态复用 .soul-history-action-armed 的语义(实心危险色 + 换文案)。
|
|
1474
|
+
// 这里只补「绑定」独有的排版:路径与元信息的折叠、列表的树形前缀、当前目录的高亮。
|
|
1475
|
+
// 当前会话目录卡:一行等宽路径 + 两行元信息(生效人设 / 来源),
|
|
1476
|
+
// 路径靠 title 给全文、靠 shortenPath 截中段 —— 不换行,避免长路径把卡片撑高
|
|
1477
|
+
'.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)}',
|
|
1478
|
+
// 路径用等宽字体:宿主主题**没有** --dsw-font-mono 这个 token(实测校对过),
|
|
1479
|
+
// 引一个不存在的 var() 只会静默回退,所以这里直接给系统等宽字体栈。
|
|
1480
|
+
'.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)}',
|
|
1481
|
+
'.soul-bindings-meta{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1482
|
+
// 输入框 + 「选择目录…」同一行:按钮不缩放(flex:none),输入框吃满剩余宽度
|
|
1483
|
+
'.soul-bindings-new{display:flex;align-items:center;gap:8px;margin-bottom:8px}',
|
|
1484
|
+
'.soul-bindings-new:last-child{margin-bottom:0}',
|
|
1485
|
+
'.soul-bindings-new label{margin:0;flex:none}',
|
|
1486
|
+
// 0.9.0-ah:按钮改用宿主 ui.Button 后,这里**不能再覆盖 background / color**
|
|
1487
|
+
// —— 原先的 color:brand-primary 压住了宿主给的白字,hover 时深底深字变全黑。
|
|
1488
|
+
// 只保留布局:这一行的输入框吃掉剩余宽度,其余控件不缩放。
|
|
1489
|
+
'.soul-bindings-new > *{flex:none}',
|
|
1490
|
+
'.soul-bindings-new input{flex:1 1 auto;min-width:0}',
|
|
1491
|
+
// 人设下拉不许被压到看不见:flex:none + 固定最小宽度
|
|
1492
|
+
'.soul-bindings-new select{flex:none;width:auto;min-width:120px}',
|
|
1493
|
+
// 降级提示:与 .soul-status 同色同字号,贴在选择器原位(说明为什么按钮不见了)
|
|
1494
|
+
'.soul-bindings-hint{margin:-2px 0 8px 0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
|
|
1495
|
+
// 列表:与 .soul-history-list 同规格(紧凑、无项目符号)
|
|
1496
|
+
'.soul-bindings-rows{display:flex;flex-direction:column;gap:6px;margin:10px 0 0 0;padding:0;list-style:none}',
|
|
1497
|
+
'.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)}',
|
|
1498
|
+
// 高亮命中当前会话的那一条:左侧一道 brand 竖条,比整块上色更安静
|
|
1499
|
+
'.soul-bindings-row[data-current="true"]{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary)}',
|
|
1500
|
+
'.soul-bindings-tree{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}',
|
|
1501
|
+
// 目录名是这一行的主体:吃掉剩余宽度,过长则省略(全文在 title)
|
|
1502
|
+
'.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)}',
|
|
1503
|
+
'.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}',
|
|
1504
|
+
'.soul-bindings-bad{flex:none;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}',
|
|
1505
|
+
'.soul-bindings-select{flex:none;width:auto;min-width:96px;max-width:160px}',
|
|
1506
|
+
// 解绑的「待确认」态:只染文字颜色(宿主按钮的底色/悬停不动,避免旧的全黑问题)
|
|
1507
|
+
'.soul-bindings-act-armed{color:var(--dsw-alias-state-error-primary)}',
|
|
1508
|
+
// 未绑定回落行:虚线框=「这是规则、不是一条记录」
|
|
1509
|
+
'.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)}',
|
|
1510
|
+
'.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}',
|
|
1511
|
+
// 「看到的不是全部」不是失败:用次要色,与红字错误(.soul-error)区分。
|
|
1512
|
+
// 宿主主题**没有**黄色 warning token(实测:--dsw-alias-label-warning /
|
|
1513
|
+
// --dsw-alias-state-warning-primary 都不存在),自己硬编一个黄色又会跟主题脱节,
|
|
1514
|
+
// 所以按「不重要但要知道」处理,与同组其它提示同色。
|
|
1515
|
+
'.soul-bindings-warning{color:var(--dsw-alias-label-secondary)}',
|
|
872
1516
|
// 开了「减少动态效果」时:光轨层与卡片停动画。
|
|
873
1517
|
// 结果提示(绿字/红字)不需要在这里照顾 —— 它们是静态文字、没有动画。
|
|
874
1518
|
'@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}.soul-persona-card{animation:none!important;transition:none}}'
|
|
@@ -930,6 +1574,7 @@ window.__ModuleLoader__.load({
|
|
|
930
1574
|
'field.tables': '表格',
|
|
931
1575
|
'hint.tables': '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中表格的使用程度。',
|
|
932
1576
|
'field.replyLength': '回复长度',
|
|
1577
|
+
'field.activePersonaName': '最近使用的预设',
|
|
933
1578
|
'hint.replyLength': '控制回答的篇幅。选「适中」时不额外约束,由 Agent 自行判断。',
|
|
934
1579
|
'field.language': '输出语言',
|
|
935
1580
|
'hint.language': '设置 Agent 回复你使用的语言,同时影响 /soul 命令的输出语言。',
|
|
@@ -940,6 +1585,12 @@ window.__ModuleLoader__.load({
|
|
|
940
1585
|
'personas.save': '保存当前为预设',
|
|
941
1586
|
// 方案 A:入口移出预设列表,与「保存设置」并列(文案用「存为预设…」表示会再问一步)
|
|
942
1587
|
'personas.saveAs': '存为预设…',
|
|
1588
|
+
// 更新到预设(0.9.0-s):与「存为预设」并列 —— 前者覆盖已有,后者新建。
|
|
1589
|
+
'personas.updateTo': '更新到预设',
|
|
1590
|
+
'personas.updateNone': '还没有可更新的自建预设。内置预设不可修改。',
|
|
1591
|
+
'personas.updateCovers': '会把「关于你」之外的人格字段(风格、特质、长度、指令)写入所选预设。',
|
|
1592
|
+
'personas.updateExcludes': '不会包含昵称、职业、介绍 —— 它们不属于预设。',
|
|
1593
|
+
'notice.personaUpdated': '已更新预设「{name}」。',
|
|
943
1594
|
'personas.saveNameLabel': '新预设名称',
|
|
944
1595
|
'personas.saveConfirm': '保存',
|
|
945
1596
|
'personas.saveCovers': '将保存:回复风格和语调、特质、回复长度、输出语言、自定义指令',
|
|
@@ -965,7 +1616,13 @@ window.__ModuleLoader__.load({
|
|
|
965
1616
|
'personas.builtin': '内置',
|
|
966
1617
|
'personas.drift': '当前配置与任何预设都不一致。',
|
|
967
1618
|
'personas.driftAction': '存为新预设',
|
|
968
|
-
|
|
1619
|
+
// 一句话就够了:用户的真实疑问只有「存了这个会不会连我的昵称也一起换掉」。
|
|
1620
|
+
// 「不可删除」不再写进正文 —— 每张内置卡上都挂着「内置」徽标、且根本没有删除按钮,
|
|
1621
|
+
// 说第二遍只是噪音。
|
|
1622
|
+
'personas.hint': '预设只含 Agent 的人格;你的昵称与自我介绍不随预设切换。',
|
|
1623
|
+
// hover 里的全文:原来那段正文原样保留在这里,需要细节的人仍能读到,
|
|
1624
|
+
// 不需要的人不必被它占两行。
|
|
1625
|
+
'personas.hintDetail': '预设有风格、特质(标题和列表 / 表情符号 / 表格)、回复长度、输出语言、自定义指令;不含「关于你」里的昵称、职业与自我介绍。内置预设随插件提供,不可删除。',
|
|
969
1626
|
'style.professional': '专业严谨',
|
|
970
1627
|
'style.casual': '轻松自然',
|
|
971
1628
|
'style.humorous': '幽默风趣',
|
|
@@ -994,9 +1651,183 @@ window.__ModuleLoader__.load({
|
|
|
994
1651
|
'meta.tables.less': '少用表格',
|
|
995
1652
|
'meta.language.zh': '中文',
|
|
996
1653
|
'meta.language.en': '英文',
|
|
1654
|
+
// —— 工作区绑定(0.8.1)——
|
|
1655
|
+
'group.bindings': '工作区绑定',
|
|
1656
|
+
'bindings.hint': '绑定记住的是目录而不是会话:在绑定的目录(含子目录)里开的会话自动用这个人设。保存后立刻对活动会话生效,其他目录不受影响。',
|
|
1657
|
+
// 当前会话目录卡
|
|
1658
|
+
'bindings.currentTitle': '当前会话目录',
|
|
1659
|
+
'bindings.noSession': '当前没有活动会话,所以没有「现在用什么」可显示。',
|
|
1660
|
+
'bindings.noCwd': '这个会话没有工作目录,任何绑定都不会命中 —— 它会用全局配置。',
|
|
1661
|
+
'bindings.effective': '生效人设',
|
|
1662
|
+
'bindings.source': '来源',
|
|
1663
|
+
'bindings.sourceGlobal': '全局配置(没有绑定的目录都归它管)',
|
|
1664
|
+
'bindings.sourceInherited': '{dir}(上级目录继承)',
|
|
1665
|
+
'bindings.choose': '选择目录…',
|
|
1666
|
+
'bindings.manualLabel': '目录路径',
|
|
1667
|
+
'bindings.manualPlaceholder': '粘贴一个绝对路径,例如 D:\\项目A',
|
|
1668
|
+
'bindings.bindDir': '绑定',
|
|
1669
|
+
// 结果提示:就地在分组内显示绿字/红字(与变更记录同款,不用 toast)
|
|
1670
|
+
'bindings.needDir': '先填一个目录,或者点「选择目录…」。',
|
|
1671
|
+
'bindings.bound': '已绑定:{dir} → {name}',
|
|
1672
|
+
'bindings.unbound': '已解绑:{dir}',
|
|
1673
|
+
'bindings.unbindMissing': '这条绑定已经不在列表里了(可能是在别处删掉的),刷新一下即可。',
|
|
1674
|
+
'bindings.pickerUnavailable': '当前连接方式无法调起系统目录选择器,请手动粘贴路径。',
|
|
1675
|
+
'bindings.moreSessions': '另有 {count} 个活动会话',
|
|
1676
|
+
// 绑定列表
|
|
1677
|
+
'bindings.listTitle': '已绑定的目录',
|
|
1678
|
+
'bindings.loading': '加载中...',
|
|
1679
|
+
'bindings.loadFailed': '无法读取工作区绑定:{reason}',
|
|
1680
|
+
'bindings.empty': '还没有绑定任何目录。',
|
|
1681
|
+
'bindings.count': '共 {count} 条 · 最多 {max} 条',
|
|
1682
|
+
'bindings.here': '当前目录',
|
|
1683
|
+
'bindings.persona': '使用的人设',
|
|
1684
|
+
'bindings.unbind': '解绑',
|
|
1685
|
+
'bindings.unbindArmed': '确认解绑?',
|
|
1686
|
+
'bindings.invalid': '人设「{name}」已不存在',
|
|
1687
|
+
'bindings.invalidHint': '这条绑定不会生效,改绑或解绑它。',
|
|
1688
|
+
'bindings.dropped': '有 {count} 行无效记录已被忽略。',
|
|
1689
|
+
'bindings.warning': '绑定表可能不完整:{reason}',
|
|
1690
|
+
'bindings.maxPath': '路径上限 {max} 字符。',
|
|
1691
|
+
'bindings.fallback': '未绑定的目录使用全局配置',
|
|
1692
|
+
'bindings.change': '改',
|
|
1693
|
+
// —— 会话级人设(0.9.0-d)——
|
|
1694
|
+
// tab 标题:与设置页「个性化」、详情页「人设预设」同一套用词。
|
|
1695
|
+
'session.tabLabel': '人设',
|
|
1696
|
+
'session.title': '本会话的人设',
|
|
1697
|
+
'session.current': '当前生效',
|
|
1698
|
+
'session.workspace': '所属工作区',
|
|
1699
|
+
'session.workspaceUnknown': '未归入任何工作区',
|
|
1700
|
+
'session.source.global': '全局配置',
|
|
1701
|
+
'session.source.workspace': '工作区「{name}」',
|
|
1702
|
+
'session.source.directory': '目录绑定',
|
|
1703
|
+
'session.source.session': '本会话临时覆盖',
|
|
1704
|
+
'session.noPersona': '自定义(与任何预设都不匹配)',
|
|
1705
|
+
'session.overrideTitle': '临时改用',
|
|
1706
|
+
'session.overrideHint': '只影响这个会话;新会话仍按工作区或全局设置。',
|
|
1707
|
+
'session.pickPersona': '换成预设…',
|
|
1708
|
+
'session.customOverrides': '自定义字段',
|
|
1709
|
+
'session.clear': '取消本会话的临时人设',
|
|
1710
|
+
'session.cleared': '已取消本会话的临时人设,现在跟随上层配置。',
|
|
1711
|
+
'session.applied': '本会话已改用「{name}」。',
|
|
1712
|
+
'session.loading': '加载中...',
|
|
1713
|
+
'session.loadFailed': '无法读取本会话的人设:{reason}',
|
|
1714
|
+
'session.applyFailed': '设置失败:{reason}',
|
|
1715
|
+
'session.noOverrides': '没有临时覆盖。',
|
|
1716
|
+
'session.badge': '有覆盖',
|
|
1717
|
+
// 输入框标签的来源层级:告诉用户「现在这个人设是哪一层定的」
|
|
1718
|
+
'quick.scopeSession': '本会话覆盖',
|
|
1719
|
+
'quick.scopeWorkspace': '工作区「{name}」',
|
|
1720
|
+
'quick.scopeDirectory': '目录绑定',
|
|
1721
|
+
'quick.scopeGlobal': '全局配置',
|
|
1722
|
+
'quick.scopeUnknown': '会话状态未知',
|
|
1723
|
+
// 设置页预设行的作用域标注:设置页没有会话上下文,写的是**全局**
|
|
1724
|
+
'personas.scopeGlobal': '全局',
|
|
1725
|
+
// 卡面按钮:与设置页「使用 / 使用中」同一套语义,只是措辞更贴会话语境
|
|
1726
|
+
'session.useCard': '用这个',
|
|
1727
|
+
'session.pickHint': '点左侧任意一张卡,在这里查看它的完整人设。',
|
|
1728
|
+
'session.chipInUse': '使用中',
|
|
1729
|
+
// —— 工作区人设(详情页,0.9.0-e)——
|
|
1730
|
+
'workspaces.title': '工作区人设',
|
|
1731
|
+
'workspaces.count': '{bound} / {total} 已绑定',
|
|
1732
|
+
'workspaces.hint': '工作区里的人设只作用于该工作区下的会话;未绑定的回落全局配置。',
|
|
1733
|
+
'workspaces.unbound': '未绑定',
|
|
1734
|
+
'workspaces.bind': '绑定人设',
|
|
1735
|
+
'workspaces.rebind': '改绑',
|
|
1736
|
+
'workspaces.unbind': '解除绑定',
|
|
1737
|
+
'workspaces.unbindArmed': '确认解除?',
|
|
1738
|
+
'workspaces.empty': '还没有工作区。在侧边栏「添加工作区」后回来这里配置人设。',
|
|
1739
|
+
'workspaces.loading': '加载中...',
|
|
1740
|
+
'workspaces.loadFailed': '无法读取工作区:{reason}',
|
|
1741
|
+
'workspaces.serverProblem': '宿主服务不可用:{reason}',
|
|
1742
|
+
'workspaces.invalid': '人设「{name}」已不存在',
|
|
1743
|
+
'workspaces.invalidHint': '这条绑定不会生效;改绑或解除它。',
|
|
1744
|
+
'workspaces.applied': '「{name}」已绑定到「{title}」。',
|
|
1745
|
+
'workspaces.unbindDone': '已解除「{title}」的人设绑定。',
|
|
1746
|
+
'workspaces.applyFailed': '设置失败:{reason}',
|
|
1747
|
+
'workspaces.pickPersona': '选择人设',
|
|
1748
|
+
// —— 会话覆盖清理(0.9.0-f)——
|
|
1749
|
+
'sessions.title': '会话覆盖',
|
|
1750
|
+
'sessions.hint': '会话里临时改过的人设。会话不存在后会先观察 30 天,确认不回再清理。',
|
|
1751
|
+
'sessions.scanning': '正在检查…',
|
|
1752
|
+
'sessions.scanFailed': '无法完成检查:{reason}',
|
|
1753
|
+
'sessions.scanIncomplete': '宿主未提供会话清单,本次不做任何判定 —— 「没看到」不等于「已删除」。',
|
|
1754
|
+
'sessions.summary': '共 {total} 个 · 待观察 {markable} · 可回收 {collectable} · 损坏 {quarantine}',
|
|
1755
|
+
'sessions.collect': '清理可回收项',
|
|
1756
|
+
'sessions.collectArmed': '确认清理 {count} 项?',
|
|
1757
|
+
'sessions.collected': '已清理 {count} 项。',
|
|
1758
|
+
'sessions.nothing': '没有可回收的条目。',
|
|
1759
|
+
'sessions.previewHint': '预览不会删除任何文件;确认后才会真的清理。',
|
|
997
1760
|
'group.tool': 'Agent 工具',
|
|
998
1761
|
'field.toolConfirm': '人设变更需确认',
|
|
999
1762
|
'hint.toolConfirm': '开启后,Agent 改人设会先在输入框处弹出审批面板(Enter 允许 / Esc 拒绝)。若会话审批通道不可用(策略为 never),则退化为待确认提议,需用 /soul confirm 处理 —— 此时建议关闭本开关,否则每次改动都要手输命令。',
|
|
1763
|
+
// —— 变更记录(0.8.0)——
|
|
1764
|
+
'group.history': '变更记录',
|
|
1765
|
+
'history.empty': '还没有任何变更记录。',
|
|
1766
|
+
'history.loading': '加载中...',
|
|
1767
|
+
'history.loadFailed': '无法读取变更记录:{reason}',
|
|
1768
|
+
'history.summary': '最近 {count} 条 · 最多保留 {max} 条',
|
|
1769
|
+
'history.prev': '上一页',
|
|
1770
|
+
'history.next': '下一页',
|
|
1771
|
+
'history.pageInfo': '第 {page} / {total} 页 · 共 {count} 条',
|
|
1772
|
+
'history.clear': '清空记录',
|
|
1773
|
+
'history.clearArmed': '确认清空?',
|
|
1774
|
+
'history.clearDone': '已清空 {count} 条变更记录。',
|
|
1775
|
+
// 按钮用「撤销」而不是「恢复」:点一条记录的结果是**把这一次改动撤掉**
|
|
1776
|
+
// (连同它之后的),不是「切回那天的状态」。
|
|
1777
|
+
'history.restore': '撤销这一次',
|
|
1778
|
+
'history.restoreArmed': '撤销?',
|
|
1779
|
+
'history.restoreDone': '已撤销 {count} 次改动。',
|
|
1780
|
+
'history.restoreUnchanged': '在撤销之前,这些改动就已经被后续改动改回去了。',
|
|
1781
|
+
'history.restorePreset': '恢复此预设',
|
|
1782
|
+
'history.restorePresetDone': '已恢复预设「{name}」。',
|
|
1783
|
+
'history.restorePresetSkipped': '未恢复「{name}」:{reason}',
|
|
1784
|
+
'history.skip.exists': '当前已有同名的预设',
|
|
1785
|
+
'history.skip.builtin': '这是内置预设',
|
|
1786
|
+
'history.skip.invalid-name': '名字不合法',
|
|
1787
|
+
'history.restoreSkippedNote': '已恢复 {count} 个,另有 {skipped} 个未恢复。',
|
|
1788
|
+
'history.diverged': '记录与当前配置对不上,无法撤销。可能有人手工改过配置文件。',
|
|
1789
|
+
'history.warning': '记录可能不完整:{reason}',
|
|
1790
|
+
'history.footer': '撤销人设配置,不影响人设预设库。',
|
|
1791
|
+
'history.fields': '涉及 {fields}',
|
|
1792
|
+
'history.field.from': '原值',
|
|
1793
|
+
'history.field.to': '新值',
|
|
1794
|
+
'history.personasAdded': '新增预设 {names}',
|
|
1795
|
+
'history.personasRemoved': '删除预设 {names}',
|
|
1796
|
+
'history.personasUpdated': '覆盖预设 {names}',
|
|
1797
|
+
// —— label:条目的人类可读上下文(0.8.0 打磨)——
|
|
1798
|
+
// 折叠行里「应用预设 · 自定义指令」答不了「应用的是什么」;有 label 时拼在来源后面。
|
|
1799
|
+
'history.labelSuffix': ':{label}',
|
|
1800
|
+
'history.source.web': '网页保存',
|
|
1801
|
+
'history.source.command': '斜杠命令',
|
|
1802
|
+
'history.source.tool': 'Agent 工具',
|
|
1803
|
+
'history.source.service': '插件调用',
|
|
1804
|
+
'history.source.persona': '应用预设',
|
|
1805
|
+
'history.source.rollback': '撤销',
|
|
1806
|
+
'history.source.restorePersona': '恢复预设',
|
|
1807
|
+
// —— 变更记录:列表渲染补充(0.8.0-d)——
|
|
1808
|
+
// 服务端没带回容量上限时的退路:宁可不提「最多保留 N 条」,也不编一个数字出来。
|
|
1809
|
+
'history.summaryCount': '最近 {count} 条',
|
|
1810
|
+
// 双入口(0.8.1):主入口在插件详情页,设置页不再留独立指引行(突兀且占地);
|
|
1811
|
+
// 变更记录的主入口在插件详情页。这里不再罗列「撤销、恢复预设、清空都在那里」
|
|
1812
|
+
// —— 那是三个按钮的名字,点进去自然看得到;罗列只会把一行提示撑成一段话。
|
|
1813
|
+
'history.entryHint': '改动都能撤销',
|
|
1814
|
+
// hover 全文:具体的「在哪儿、能做什么」。正文只留一句判断,细节按需展开。
|
|
1815
|
+
'history.entryHintMore': '变更记录在插件详情页(插件管理 → 已安装插件 → dsh-soul):撤销某次改动及其之后的全部改动、恢复被删的预设、清空记录,都在那里。',
|
|
1816
|
+
// —— 变更记录保留条数(0.8.0 打磨)——
|
|
1817
|
+
'history.maxEntries': '保留条数',
|
|
1818
|
+
'history.maxEntriesHint': '变更记录最多保留多少条。超出后自动丢弃最旧的记录;条数越多,可撤销的范围越大。',
|
|
1819
|
+
'history.maxEntriesDefault': '{count} 条(默认)',
|
|
1820
|
+
'history.maxEntriesOption': '{count} 条',
|
|
1821
|
+
'history.maxEntriesSaved': '保留条数已改为 {count} 条。',
|
|
1822
|
+
'history.timeJustNow': '刚刚',
|
|
1823
|
+
'history.timeMinutes': '{count} 分钟前',
|
|
1824
|
+
'history.timeHours': '{count} 小时前',
|
|
1825
|
+
'history.timeDays': '{count} 天前',
|
|
1826
|
+
// 空值必须显式画出来:留空会被读成「渲染坏了」,而「这次把它清空了」是一条事实
|
|
1827
|
+
'history.valueEmpty': '(空)',
|
|
1828
|
+
// 服务端不记空条目,所以这一句正常情况下到不了屏幕;但记录文件是可以被手工
|
|
1829
|
+
// 改坏的,真遇到时宁可写「无字段变化」,也不要显示成裸键名 history.emptyFields。
|
|
1830
|
+
'history.emptyFields': '无字段变化',
|
|
1000
1831
|
'button.save': '保存设置',
|
|
1001
1832
|
'button.saving': '保存中...',
|
|
1002
1833
|
'button.cancel': '取消',
|
|
@@ -1011,6 +1842,11 @@ window.__ModuleLoader__.load({
|
|
|
1011
1842
|
'notice.reset': '已重置为默认值。',
|
|
1012
1843
|
'notice.resetRecovered': '已重置为默认值(损坏的配置已备份)。',
|
|
1013
1844
|
'notice.personaSaved': '已保存预设「{name}」。',
|
|
1845
|
+
'notice.personaSavedAndApplied': '已保存并应用预设「{name}」。',
|
|
1846
|
+
'personas.applyAfterSave': '保存后立即应用',
|
|
1847
|
+
'sidebar.personaTip': '人设:{name}',
|
|
1848
|
+
'sidebar.personaRow': '人设',
|
|
1849
|
+
'personas.applyAfterSaveHint': '不勾选则保留当前配置不变(表单恢复到已保存的设置)',
|
|
1014
1850
|
'notice.personaUsed': '已应用预设「{name}」。',
|
|
1015
1851
|
'notice.personaUnchanged': '预设与当前配置一致。',
|
|
1016
1852
|
'notice.personaDeleted': '已删除预设「{name}」。',
|
|
@@ -1091,6 +1927,7 @@ window.__ModuleLoader__.load({
|
|
|
1091
1927
|
'field.tables': 'Tables',
|
|
1092
1928
|
'hint.tables': 'An extra trait layered on top of style & tone. Controls how much answers rely on tables.',
|
|
1093
1929
|
'field.replyLength': 'Reply length',
|
|
1930
|
+
'field.activePersonaName': 'Last used persona',
|
|
1094
1931
|
'hint.replyLength': 'Controls how long answers are. "Balanced" adds no constraint and lets the agent decide.',
|
|
1095
1932
|
'field.language': 'Output language',
|
|
1096
1933
|
'hint.language': 'Sets the language the agent replies in; also affects the /soul command output.',
|
|
@@ -1100,6 +1937,11 @@ window.__ModuleLoader__.load({
|
|
|
1100
1937
|
'group.personas': 'Personas',
|
|
1101
1938
|
'personas.save': 'Save current as persona',
|
|
1102
1939
|
'personas.saveAs': 'Save as persona…',
|
|
1940
|
+
'personas.updateTo': 'Update preset',
|
|
1941
|
+
'personas.updateNone': 'No editable custom presets yet. Built-in presets cannot be modified.',
|
|
1942
|
+
'personas.updateCovers': 'Writes the persona fields (style, traits, length, instructions) into the selected preset.',
|
|
1943
|
+
'personas.updateExcludes': 'Nickname, occupation and bio are not included — they are not part of a persona.',
|
|
1944
|
+
'notice.personaUpdated': 'Updated preset "{name}".',
|
|
1103
1945
|
'personas.saveNameLabel': 'New persona name',
|
|
1104
1946
|
'personas.saveConfirm': 'Save',
|
|
1105
1947
|
'personas.saveCovers': 'Saves: reply style & tone, traits, reply length, reply language, custom instructions',
|
|
@@ -1124,7 +1966,8 @@ window.__ModuleLoader__.load({
|
|
|
1124
1966
|
'personas.builtin': 'Built-in',
|
|
1125
1967
|
'personas.drift': 'The current config matches no preset.',
|
|
1126
1968
|
'personas.driftAction': 'Save as new preset',
|
|
1127
|
-
'personas.hint': '
|
|
1969
|
+
'personas.hint': 'Personas hold only the agent personality; your nickname and bio never switch with a persona.',
|
|
1970
|
+
'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
1971
|
'style.professional': 'Professional',
|
|
1129
1972
|
'style.casual': 'Casual',
|
|
1130
1973
|
'style.humorous': 'Humorous',
|
|
@@ -1153,9 +1996,170 @@ window.__ModuleLoader__.load({
|
|
|
1153
1996
|
'meta.tables.less': 'Fewer tables',
|
|
1154
1997
|
'meta.language.zh': 'Chinese',
|
|
1155
1998
|
'meta.language.en': 'English',
|
|
1999
|
+
// —— Workspace bindings (0.8.1) ——
|
|
2000
|
+
'group.bindings': 'Workspace bindings',
|
|
2001
|
+
'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.',
|
|
2002
|
+
'bindings.currentTitle': 'Current session directory',
|
|
2003
|
+
'bindings.noSession': 'No active session, so there is no "in effect right now" to show.',
|
|
2004
|
+
'bindings.noCwd': 'This session has no working directory, so no binding can match it — it uses the global config.',
|
|
2005
|
+
'bindings.effective': 'Persona in effect',
|
|
2006
|
+
'bindings.source': 'Source',
|
|
2007
|
+
'bindings.sourceGlobal': 'Global config (everything unbound falls back to it)',
|
|
2008
|
+
'bindings.sourceInherited': '{dir} (inherited from a parent directory)',
|
|
2009
|
+
'bindings.choose': 'Choose directory…',
|
|
2010
|
+
'bindings.manualLabel': 'Directory path',
|
|
2011
|
+
'bindings.manualPlaceholder': 'Paste an absolute path, e.g. D:\\project-a',
|
|
2012
|
+
'bindings.bindDir': 'Bind',
|
|
2013
|
+
'bindings.needDir': 'Enter a directory first, or use "Choose directory…".',
|
|
2014
|
+
'bindings.bound': 'Bound: {dir} → {name}',
|
|
2015
|
+
'bindings.unbound': 'Unbound: {dir}',
|
|
2016
|
+
'bindings.unbindMissing': 'This binding is no longer in the list (it was probably removed elsewhere); reload to refresh.',
|
|
2017
|
+
'bindings.pickerUnavailable': 'This connection cannot open the system directory picker; paste the path instead.',
|
|
2018
|
+
'bindings.moreSessions': '{count} more active session(s)',
|
|
2019
|
+
'bindings.listTitle': 'Bound directories',
|
|
2020
|
+
'bindings.loading': 'Loading...',
|
|
2021
|
+
'bindings.loadFailed': 'Could not read the workspace bindings: {reason}',
|
|
2022
|
+
'bindings.empty': 'No directory is bound yet.',
|
|
2023
|
+
'bindings.count': '{count} total · up to {max}',
|
|
2024
|
+
'bindings.here': 'This directory',
|
|
2025
|
+
'bindings.persona': 'Persona',
|
|
2026
|
+
'bindings.unbind': 'Unbind',
|
|
2027
|
+
'bindings.unbindArmed': 'Unbind it?',
|
|
2028
|
+
'bindings.invalid': 'Persona "{name}" no longer exists',
|
|
2029
|
+
'bindings.invalidHint': 'This binding will not take effect; rebind or remove it.',
|
|
2030
|
+
'bindings.dropped': '{count} invalid line(s) were ignored.',
|
|
2031
|
+
'bindings.warning': 'The binding table may be incomplete: {reason}',
|
|
2032
|
+
'bindings.maxPath': 'Path limit {max} characters.',
|
|
2033
|
+
'bindings.fallback': 'Unbound directories use the global config',
|
|
2034
|
+
'bindings.change': 'Change',
|
|
2035
|
+
'session.tabLabel': 'Persona',
|
|
2036
|
+
'session.title': 'Persona for this session',
|
|
2037
|
+
'session.current': 'In effect',
|
|
2038
|
+
'session.workspace': 'Workspace',
|
|
2039
|
+
'session.workspaceUnknown': 'Not in any workspace',
|
|
2040
|
+
'session.source.global': 'Global config',
|
|
2041
|
+
'session.source.workspace': 'Workspace "{name}"',
|
|
2042
|
+
'session.source.directory': 'Directory binding',
|
|
2043
|
+
'session.source.session': 'Temporary override for this session',
|
|
2044
|
+
'session.noPersona': 'Custom (matches no preset)',
|
|
2045
|
+
'session.overrideTitle': 'Temporarily use',
|
|
2046
|
+
'session.overrideHint': 'Affects this session only; new sessions follow the workspace or global setting.',
|
|
2047
|
+
'session.pickPersona': 'Switch to a preset…',
|
|
2048
|
+
'session.customOverrides': 'Custom fields',
|
|
2049
|
+
'session.clear': 'Remove this session\'s temporary persona',
|
|
2050
|
+
'session.cleared': "Removed this session's temporary persona; it now follows the upper scopes.",
|
|
2051
|
+
'session.applied': 'This session now uses "{name}".',
|
|
2052
|
+
'session.loading': 'Loading...',
|
|
2053
|
+
'session.loadFailed': 'Could not read this session\'s persona: {reason}',
|
|
2054
|
+
'session.applyFailed': 'Could not apply: {reason}',
|
|
2055
|
+
'session.noOverrides': 'No temporary override.',
|
|
2056
|
+
'session.badge': 'overridden',
|
|
2057
|
+
'quick.scopeSession': 'this session',
|
|
2058
|
+
'quick.scopeWorkspace': 'workspace "{name}"',
|
|
2059
|
+
'quick.scopeDirectory': 'directory binding',
|
|
2060
|
+
'quick.scopeGlobal': 'global config',
|
|
2061
|
+
'quick.scopeUnknown': 'session state unknown',
|
|
2062
|
+
'personas.scopeGlobal': 'Global',
|
|
2063
|
+
'session.useCard': 'Use this',
|
|
2064
|
+
'session.pickHint': 'Pick a card on the left to see its full persona here.',
|
|
2065
|
+
'session.chipInUse': 'In use',
|
|
2066
|
+
'workspaces.title': 'Workspace personas',
|
|
2067
|
+
'workspaces.count': '{bound} / {total} bound',
|
|
2068
|
+
'workspaces.hint': 'A workspace persona applies only to sessions opened in that workspace; unbound workspaces fall back to the global config.',
|
|
2069
|
+
'workspaces.unbound': 'Not bound',
|
|
2070
|
+
'workspaces.bind': 'Bind persona',
|
|
2071
|
+
'workspaces.rebind': 'Change',
|
|
2072
|
+
'workspaces.unbind': 'Unbind',
|
|
2073
|
+
'workspaces.unbindArmed': 'Unbind?',
|
|
2074
|
+
'workspaces.empty': 'No workspaces yet. Add one from the sidebar, then come back here to set its persona.',
|
|
2075
|
+
'workspaces.loading': 'Loading...',
|
|
2076
|
+
'workspaces.loadFailed': 'Could not read workspaces: {reason}',
|
|
2077
|
+
'workspaces.serverProblem': 'Host service unavailable: {reason}',
|
|
2078
|
+
'workspaces.invalid': 'Persona "{name}" no longer exists',
|
|
2079
|
+
'workspaces.invalidHint': 'This binding will not take effect; rebind or remove it.',
|
|
2080
|
+
'workspaces.applied': '"{name}" is now bound to "{title}".',
|
|
2081
|
+
'workspaces.unbindDone': 'Unbound the persona from "{title}".',
|
|
2082
|
+
'workspaces.applyFailed': 'Could not apply: {reason}',
|
|
2083
|
+
'workspaces.pickPersona': 'Pick a persona',
|
|
2084
|
+
'sessions.title': 'Session overrides',
|
|
2085
|
+
'sessions.hint': 'Personas changed for a single session. After the session disappears they are watched for 30 days before cleanup.',
|
|
2086
|
+
'sessions.scanning': 'Checking…',
|
|
2087
|
+
'sessions.scanFailed': 'Could not finish the check: {reason}',
|
|
2088
|
+
'sessions.scanIncomplete': 'The host did not provide a session list, so nothing was decided — "not seen" is not "deleted".',
|
|
2089
|
+
'sessions.summary': '{total} total · {markable} watching · {collectable} collectable · {quarantine} corrupt',
|
|
2090
|
+
'sessions.collect': 'Clean up collectable',
|
|
2091
|
+
'sessions.collectArmed': 'Delete {count} item(s)?',
|
|
2092
|
+
'sessions.collected': 'Cleaned up {count} item(s).',
|
|
2093
|
+
'sessions.nothing': 'Nothing to collect.',
|
|
2094
|
+
'sessions.previewHint': 'Previewing deletes nothing; files are removed only after you confirm.',
|
|
1156
2095
|
'group.tool': 'Agent tools',
|
|
1157
2096
|
'field.toolConfirm': 'Require confirmation for persona changes',
|
|
1158
2097
|
'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.',
|
|
2098
|
+
// —— Change history (0.8.0) ——
|
|
2099
|
+
'group.history': 'Change history',
|
|
2100
|
+
'history.empty': 'No changes recorded yet.',
|
|
2101
|
+
'history.loading': 'Loading...',
|
|
2102
|
+
'history.loadFailed': 'Could not read the change history: {reason}',
|
|
2103
|
+
'history.summary': 'Last {count} · keeps up to {max}',
|
|
2104
|
+
'history.prev': 'Previous',
|
|
2105
|
+
'history.next': 'Next',
|
|
2106
|
+
'history.pageInfo': 'Page {page} of {total} · {count} entries',
|
|
2107
|
+
'history.clear': 'Clear history',
|
|
2108
|
+
'history.clearArmed': 'Clear it?',
|
|
2109
|
+
'history.clearDone': 'Cleared {count} change records.',
|
|
2110
|
+
// "Undo" rather than "restore": clicking a record reverts **that change**
|
|
2111
|
+
// (and everything after it), it does not jump back to that day's state.
|
|
2112
|
+
'history.restore': 'Undo this change',
|
|
2113
|
+
'history.restoreArmed': 'Undo?',
|
|
2114
|
+
'history.restoreDone': 'Undid {count} change(s).',
|
|
2115
|
+
'history.restoreUnchanged': 'Those changes had already been reverted by later edits.',
|
|
2116
|
+
'history.restorePreset': 'Restore this preset',
|
|
2117
|
+
'history.restorePresetDone': 'Restored preset "{name}".',
|
|
2118
|
+
'history.restorePresetSkipped': 'Did not restore "{name}": {reason}',
|
|
2119
|
+
'history.skip.exists': 'a preset with that name already exists',
|
|
2120
|
+
'history.skip.builtin': 'it is a built-in preset',
|
|
2121
|
+
'history.skip.invalid-name': 'the name is not valid',
|
|
2122
|
+
'history.restoreSkippedNote': 'Restored {count}; {skipped} were not restored.',
|
|
2123
|
+
'history.diverged': 'The history does not match the current config, so it cannot be undone. The config file may have been edited by hand.',
|
|
2124
|
+
'history.warning': 'The history may be incomplete: {reason}',
|
|
2125
|
+
'history.footer': 'Undoes persona settings only; your preset library is untouched.',
|
|
2126
|
+
'history.fields': 'Touches {fields}',
|
|
2127
|
+
'history.field.from': 'was',
|
|
2128
|
+
'history.field.to': 'now',
|
|
2129
|
+
'history.personasAdded': 'Added preset {names}',
|
|
2130
|
+
'history.personasRemoved': 'Deleted preset {names}',
|
|
2131
|
+
'history.personasUpdated': 'Overwrote preset {names}',
|
|
2132
|
+
'history.labelSuffix': ': {label}',
|
|
2133
|
+
'history.source.web': 'Web save',
|
|
2134
|
+
'history.source.command': 'Slash command',
|
|
2135
|
+
'history.source.tool': 'Agent tool',
|
|
2136
|
+
'history.source.service': 'Plugin call',
|
|
2137
|
+
'history.source.persona': 'Preset applied',
|
|
2138
|
+
'history.source.rollback': 'Undo',
|
|
2139
|
+
'history.source.restorePersona': 'Preset restored',
|
|
2140
|
+
// —— Change history: list rendering (0.8.0-d) ——
|
|
2141
|
+
// Fallback for when the server did not report a capacity: better to omit
|
|
2142
|
+
// "keeps up to N" than to invent a number that reads like a promise.
|
|
2143
|
+
'history.summaryCount': 'Last {count}',
|
|
2144
|
+
// Dual entry (0.8.1): the full view lives on the plugin detail page; the
|
|
2145
|
+
// settings group becomes a pointer line so a low-frequency feature stops
|
|
2146
|
+
// occupying the settings page permanently.
|
|
2147
|
+
'history.entryHint': 'Every change can be undone',
|
|
2148
|
+
'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.',
|
|
2149
|
+
// —— Change history retention (0.8.0 polish) ——
|
|
2150
|
+
'history.maxEntries': 'Entries kept',
|
|
2151
|
+
'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.',
|
|
2152
|
+
'history.maxEntriesDefault': '{count} entries (default)',
|
|
2153
|
+
'history.maxEntriesOption': '{count} entries',
|
|
2154
|
+
'history.maxEntriesSaved': 'Entries kept is now {count}.',
|
|
2155
|
+
'history.timeJustNow': 'just now',
|
|
2156
|
+
'history.timeMinutes': '{count} min ago',
|
|
2157
|
+
'history.timeHours': '{count} h ago',
|
|
2158
|
+
'history.timeDays': '{count} d ago',
|
|
2159
|
+
// An empty value must be drawn explicitly: leaving it blank reads as "broken",
|
|
2160
|
+
// while "it was cleared" is itself a fact worth showing.
|
|
2161
|
+
'history.valueEmpty': '(empty)',
|
|
2162
|
+
'history.emptyFields': 'No field changes',
|
|
1159
2163
|
'button.save': 'Save Settings',
|
|
1160
2164
|
'button.saving': 'Saving...',
|
|
1161
2165
|
'button.cancel': 'Cancel',
|
|
@@ -1168,6 +2172,11 @@ window.__ModuleLoader__.load({
|
|
|
1168
2172
|
'notice.reset': 'Reset to defaults.',
|
|
1169
2173
|
'notice.resetRecovered': 'Reset to defaults (the damaged config was backed up).',
|
|
1170
2174
|
'notice.personaSaved': 'Saved persona "{name}".',
|
|
2175
|
+
'notice.personaSavedAndApplied': 'Saved and applied persona "{name}".',
|
|
2176
|
+
'personas.applyAfterSave': 'Apply immediately after saving',
|
|
2177
|
+
'sidebar.personaTip': 'Persona: {name}',
|
|
2178
|
+
'sidebar.personaRow': 'Persona',
|
|
2179
|
+
'personas.applyAfterSaveHint': 'Leave unchecked to keep the current config unchanged (the form reverts to the saved settings)',
|
|
1171
2180
|
'notice.personaUsed': 'Applied persona "{name}".',
|
|
1172
2181
|
'notice.personaUnchanged': 'Persona matches current config.',
|
|
1173
2182
|
'notice.personaDeleted': 'Deleted persona "{name}".',
|
|
@@ -1264,14 +2273,110 @@ window.__ModuleLoader__.load({
|
|
|
1264
2273
|
{ value: 'en', label: 'English' }
|
|
1265
2274
|
]
|
|
1266
2275
|
|
|
1267
|
-
//
|
|
1268
|
-
const
|
|
2276
|
+
// 设置页表单驱动的字段(dirty 检测与保存载荷共用)。
|
|
2277
|
+
const FORM_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
|
|
2278
|
+
|
|
2279
|
+
// 由**别的界面**就地管理、因而不在这张表单里的配置字段:
|
|
2280
|
+
// · `historyMaxEntries` —— 插件详情页的变更记录区块(改完即时落盘,不经保存按钮);
|
|
2281
|
+
// · `workspaces` —— 插件详情页的「工作区人设」区块(同上,0.9.0)。
|
|
2282
|
+
// 两者都是复合/非表单语义:设置页的「保存设置」不该顺手提交它们,否则用户在详情页
|
|
2283
|
+
// 改完的值会被设置页那份**不知道它改过**的旧表单覆盖回去。
|
|
2284
|
+
// activePersonaName(0.9.0-p)同属外部管理:由「使用预设」动作写入,
|
|
2285
|
+
// 设置页表单不直接编辑它 —— 但 FIELD_KEYS 必须覆盖(dirty 对账不漏字段)。
|
|
2286
|
+
const EXTERNALLY_MANAGED_FIELDS = ['historyMaxEntries', 'workspaces', 'activePersonaName']
|
|
2287
|
+
|
|
2288
|
+
// 配置字段全集(宿主 DEFAULT_CONFIG 的对账基准)。
|
|
2289
|
+
// 顺序与 DEFAULT_CONFIG 一致,便于人眼对账;重复字段会让 dirty 判定重复计算。
|
|
2290
|
+
// **表单只认 FORM_FIELD_KEYS** —— 拿全集去判 dirty 会让表单对着一个它并不持有的
|
|
2291
|
+
// 字段反复比较,语义混乱(当前恰好因为两边都是 undefined 而等价,但那是巧合)。
|
|
2292
|
+
const FIELD_KEYS = [...FORM_FIELD_KEYS, ...EXTERNALLY_MANAGED_FIELDS]
|
|
2293
|
+
|
|
2294
|
+
// 变更记录保留条数的可选值。**只放整数档位,不做自由输入**:
|
|
2295
|
+
// 这是个一次设定、此后不再碰的容量参数,自由输入只会带来
|
|
2296
|
+
// 「输 30 被夹到 50」这种需要额外解释的状态。默认档(200)单独标注出来,
|
|
2297
|
+
// 用户想回到出厂设置时一眼能找到。
|
|
2298
|
+
// (0.8.1 起这个下拉在插件详情页的变更记录区块内,不在设置页 —— 见下方
|
|
2299
|
+
// `HISTORY_MAX_ENTRIES_OPTIONS` 所在位置的说明。)
|
|
1269
2300
|
|
|
1270
2301
|
// 其中真正参与 compilePrompt 的字段子集。
|
|
1271
2302
|
// 提示词预览只依赖它们:改光轨颜色 / 粗细、切换 set_persona 确认模式都不会改变
|
|
1272
2303
|
// 编译结果,不该因此重新请求,也不该让预览被标成「未保存」。
|
|
1273
2304
|
const PROMPT_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
|
|
1274
2305
|
|
|
2306
|
+
// —— 变更记录(0.8.0)——
|
|
2307
|
+
//
|
|
2308
|
+
// 审计字段的文案键。**必须覆盖 lib/history.mjs 的 AUDIT_FIELDS**(= DEFAULT_CONFIG 全键):
|
|
2309
|
+
// 漏掉一个,时间线里就会露出一串裸键名(`trailWidth`)。有断言盯着这张表。
|
|
2310
|
+
// 复用设置页既有文案而不是另写一份:同一样东西在两处叫不同名字,用户会以为不是一回事。
|
|
2311
|
+
const HISTORY_FIELD_LABELS = {
|
|
2312
|
+
enabled: 'toggle.enabled',
|
|
2313
|
+
nickname: 'field.nickname',
|
|
2314
|
+
occupation: 'field.occupation',
|
|
2315
|
+
bio: 'field.bio',
|
|
2316
|
+
style: 'field.style',
|
|
2317
|
+
headingLists: 'field.headingLists',
|
|
2318
|
+
emoji: 'field.emoji',
|
|
2319
|
+
tables: 'field.tables',
|
|
2320
|
+
replyLength: 'field.replyLength',
|
|
2321
|
+
language: 'field.language',
|
|
2322
|
+
customInstructions: 'field.instructions',
|
|
2323
|
+
requireToolConfirmation: 'field.toolConfirm',
|
|
2324
|
+
trailEnabled: 'trail.enable',
|
|
2325
|
+
trailColor: 'trail.color',
|
|
2326
|
+
trailSpeed: 'trail.speed',
|
|
2327
|
+
trailWidth: 'trail.width',
|
|
2328
|
+
// 保留条数本身也是被审计的字段:改它同样会留一条记录(否则用户改了容量,
|
|
2329
|
+
// 记录里查不到是谁改的),所以它必须有自己的标签。
|
|
2330
|
+
historyMaxEntries: 'history.maxEntries',
|
|
2331
|
+
// 最近一次「使用预设」实际点的名字(0.9.0-p):切预设同样要留痕,
|
|
2332
|
+
// 否则变更记录里它显示成裸键名。
|
|
2333
|
+
activePersonaName: 'field.activePersonaName'
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// 与 lib/history.mjs 的 HISTORY_SOURCES 对齐;认不出的来源原样显示。
|
|
2337
|
+
const HISTORY_SOURCES = ['web', 'command', 'tool', 'service', 'persona', 'rollback', 'restorePersona']
|
|
2338
|
+
|
|
2339
|
+
// 一次最多铺开多少条。记录上限是 200 条,全渲染会让这一组比整个设置页还长;
|
|
2340
|
+
// 超出的折成一行「还有 N 条」,想看完的人再点一次。
|
|
2341
|
+
// 变更记录每页条数(0.9.0-ai:原先「先铺 20 条 + 还有 N 条 → 全部摊开」,
|
|
2342
|
+
// 摊开后区块会很长;改为分页,页面高度稳定,也便于回看早期记录。
|
|
2343
|
+
const HISTORY_PAGE_SIZE = 20
|
|
2344
|
+
|
|
2345
|
+
// —— 变更记录保留条数(0.8.1 从设置页迁到插件详情页)——
|
|
2346
|
+
//
|
|
2347
|
+
// 它描述的是「这个区块自己保留多少条」,放在它**自己旁边**才是对话该发生的位置:
|
|
2348
|
+
// 用户在详情页看到「最近 37 条 · 最多保留 200 条」,想改这个 200 时就地能改,
|
|
2349
|
+
// 而不必先去设置页的「人设预设」组里找 —— 那个位置解释不清(它既不是预设,
|
|
2350
|
+
// 也只是「变更记录」这个分组的容量,而设置页早已没有变更记录分组了)。
|
|
2351
|
+
//
|
|
2352
|
+
// 档位由服务端 HISTORY_MAX_ENTRIES 与 clampHistoryMaxEntries 兜底;客户端这组
|
|
2353
|
+
// 常量只负责「给哪些选项」。上限 500 因为条目只存变化字段、远在 512 KB 之内;
|
|
2354
|
+
// 下限 50 因为再小则「上一轮就没了」。
|
|
2355
|
+
const HISTORY_MAX_ENTRIES_OPTIONS = [50, 100, 200, 500]
|
|
2356
|
+
const HISTORY_MAX_ENTRIES_DEFAULT = 200
|
|
2357
|
+
|
|
2358
|
+
// 枚举字段 → 既有文案键的映射表(模块级:SoulSettings 与 SoulHistorySection 共用)。
|
|
2359
|
+
// 枚举值复用设置页的既有文案键(`style.*` / `trait.*` / `trail.speed.*` …),
|
|
2360
|
+
// 布尔用「已开启 / 未开启」,其余原样显示。
|
|
2361
|
+
const HISTORY_ENUM_KEYS = {
|
|
2362
|
+
style: (value) => `style.${value}`,
|
|
2363
|
+
headingLists: (value) => `trait.headingLists.${value}`,
|
|
2364
|
+
emoji: (value) => `trait.emoji.${value}`,
|
|
2365
|
+
tables: (value) => `trait.tables.${value}`,
|
|
2366
|
+
replyLength: (value) => `trait.replyLength.${value}`,
|
|
2367
|
+
trailSpeed: (value) => `trail.speed.${value}`,
|
|
2368
|
+
trailWidth: (value) => `trail.width.${value}`
|
|
2369
|
+
}
|
|
2370
|
+
const HISTORY_ENUM_VALUES = {
|
|
2371
|
+
style: STYLE_VALUES,
|
|
2372
|
+
headingLists: TRAIT_VALUES,
|
|
2373
|
+
emoji: TRAIT_VALUES,
|
|
2374
|
+
tables: TRAIT_VALUES,
|
|
2375
|
+
replyLength: REPLY_LENGTH_VALUES,
|
|
2376
|
+
trailSpeed: TRAIL_SPEED_VALUES,
|
|
2377
|
+
trailWidth: TRAIL_WIDTH_VALUES
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1275
2380
|
// 提示词小图标:hover / 键盘聚焦都展示说明文字。
|
|
1276
2381
|
// 此前只有 :hover 可达,且提示节点用 visibility:hidden —— 那会把它移出无障碍树,
|
|
1277
2382
|
// 键盘与读屏用户拿不到字段说明。这里补 tabIndex 让它可以聚焦,并在 :focus-visible
|
|
@@ -1297,22 +2402,1566 @@ window.__ModuleLoader__.load({
|
|
|
1297
2402
|
)
|
|
1298
2403
|
}
|
|
1299
2404
|
|
|
1300
|
-
// 可折叠设置面板:同一时间只展开一个区域,并持久化用户最后的选择。
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
2405
|
+
// 可折叠设置面板:同一时间只展开一个区域,并持久化用户最后的选择。
|
|
2406
|
+
//
|
|
2407
|
+
// 曾经这里有个 `hint` prop,把问号提示渲染在折叠标题旁的 head 里。用户反馈:
|
|
2408
|
+
// 折叠态挂问号等于把说明放在「还没展开就能看到」的位置,而它解释的恰恰是展开后
|
|
2409
|
+
// 的东西。现在说明一律写进 body 里(能直接读的文字优先于 hover),
|
|
2410
|
+
// head 层随之取消 —— 触发器恢复成 section 的直接子元素。
|
|
2411
|
+
function SoulAccordion(props) {
|
|
2412
|
+
const { id, title, summary, open, onToggle, children } = props
|
|
2413
|
+
return e('section', { className: `soul-accordion${open ? ' soul-accordion-open' : ''}` },
|
|
2414
|
+
e('button', {
|
|
2415
|
+
type: 'button',
|
|
2416
|
+
className: 'soul-accordion-trigger',
|
|
2417
|
+
'aria-expanded': open,
|
|
2418
|
+
'aria-controls': `${id}-body`,
|
|
2419
|
+
onClick: onToggle
|
|
2420
|
+
},
|
|
2421
|
+
e('span', { className: 'soul-accordion-title' }, title),
|
|
2422
|
+
e('span', { className: 'soul-accordion-summary' }, summary),
|
|
2423
|
+
e('span', { className: 'soul-accordion-icon', 'aria-hidden': true }, open ? '−' : '+')
|
|
2424
|
+
),
|
|
2425
|
+
open && e('div', { id: `${id}-body`, className: 'soul-accordion-body' }, children)
|
|
2426
|
+
)
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// —— 变更记录(0.8.0 → 0.8.1 双入口改造)——
|
|
2430
|
+
//
|
|
2431
|
+
// 0.8.1 起主入口是**插件详情页**(plugins.detail.section 槽位):
|
|
2432
|
+
// 变更记录是低频的回顾性信息,长期占着设置页主区不值得;详情页地方大,
|
|
2433
|
+
// 可以按宿主 detailSection 的排版充分展示。设置页只留一行指引(见 SoulSettings)。
|
|
2434
|
+
//
|
|
2435
|
+
// 组件收 { useSoulController, controller, t, subject }:subject 由宿主传入
|
|
2436
|
+
//(bundle / item / row 三种形态)—— 只有 dsh-soul 自己的详情页才渲染,
|
|
2437
|
+
// 其他插件的详情页必须渲染 null(槽位契约原话:"renders null for a subject
|
|
2438
|
+
// it has nothing for")。挂载时才拉取:比设置页的「展开才拉」更懒。
|
|
2439
|
+
function SoulHistorySection(props) {
|
|
2440
|
+
const { useSoulController, controller } = props
|
|
2441
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
2442
|
+
const subject = props.subject || null
|
|
2443
|
+
// 只服务自己的详情页。bundle 形态才有 pkg.name(item/row 形态没有,
|
|
2444
|
+
// 一律不渲染 —— 它们是另一个层级的页面)。
|
|
2445
|
+
if (!subject || subject.kind !== 'bundle' || !subject.pkg || subject.pkg.name !== 'dsh-soul') return null
|
|
2446
|
+
|
|
2447
|
+
const state = useSoulController((state) => state)
|
|
2448
|
+
const { history, historyWarning, historyMax, historyError, historyMaxEntries } = state
|
|
2449
|
+
|
|
2450
|
+
// 结果提示:与设置页同款双轨 —— 成功绿字(role=status)、失败红字(role=alert),
|
|
2451
|
+
// 失败压过成功。独立于 pendingUndo/pendingClear(那两个有 5s 超时)。
|
|
2452
|
+
const [notice, setNotice] = React.useState(null)
|
|
2453
|
+
const [errorNotice, setErrorNotice] = React.useState(null)
|
|
2454
|
+
|
|
2455
|
+
// —— 保留条数(0.8.1 从设置页迁来)——
|
|
2456
|
+
//
|
|
2457
|
+
// 它是**配置字段**(落 soul-config.json、进审计),但在这里是「就地的容量参数」:
|
|
2458
|
+
// 改完立刻 POST,不等设置页那个保存按钮——用户在详情页改完就走人了,
|
|
2459
|
+
// 要求他再去设置页点一次保存,等于挖了个坑。
|
|
2460
|
+
//
|
|
2461
|
+
// 乐观更新 + 失败回滚:先把本地值改成新值(下拉立刻响应),服务端拒绝
|
|
2462
|
+
// (校验 / 磁盘损坏)时退回原值并报红字。不做「保存中禁用」——这是个
|
|
2463
|
+
// 一次性动作,且服务端有写队列,连点几次只会依次落盘。
|
|
2464
|
+
const [retentionPending, setRetentionPending] = React.useState(false)
|
|
2465
|
+
const savedRetention = Number.isFinite(historyMaxEntries) ? historyMaxEntries : HISTORY_MAX_ENTRIES_DEFAULT
|
|
2466
|
+
|
|
2467
|
+
// —— 二次确认与浏览状态(从 SoulSettings 平移,语义不变)——
|
|
2468
|
+
const [pendingUndo, setPendingUndo] = React.useState(null)
|
|
2469
|
+
const [pendingClear, setPendingClear] = React.useState(false)
|
|
2470
|
+
// 展开的是哪一条(存 id 而不是下标:重拉后顺序可能变,存下标会展开错行)。
|
|
2471
|
+
const [expandedEntry, setExpandedEntry] = React.useState(null)
|
|
2472
|
+
// 「还有 N 条」被点开后就一直摊开:记录只在写配置时变。
|
|
2473
|
+
const [historyPage, setHistoryPage] = React.useState(0)
|
|
2474
|
+
// 分页派生值(0.9.0-ai):记录被清空、或保留条数调小时,页码自动收敛到最后一页 ——
|
|
2475
|
+
// 否则会停在一个不存在的页上(列表空白,分页器却还写着第 5 页)。
|
|
2476
|
+
const historyEntries = Array.isArray(history) ? history : []
|
|
2477
|
+
const historyPageCount = Math.max(1, Math.ceil(historyEntries.length / HISTORY_PAGE_SIZE))
|
|
2478
|
+
const historyPageIndex = Math.min(Math.max(historyPage, 0), historyPageCount - 1)
|
|
2479
|
+
const pageEntries = historyEntries.slice(
|
|
2480
|
+
historyPageIndex * HISTORY_PAGE_SIZE,
|
|
2481
|
+
(historyPageIndex + 1) * HISTORY_PAGE_SIZE
|
|
2482
|
+
)
|
|
2483
|
+
|
|
2484
|
+
React.useEffect(() => {
|
|
2485
|
+
if (pendingUndo === null && !pendingClear) return
|
|
2486
|
+
const timer = setTimeout(() => {
|
|
2487
|
+
setPendingUndo(null)
|
|
2488
|
+
setPendingClear(false)
|
|
2489
|
+
}, 5000)
|
|
2490
|
+
return () => clearTimeout(timer)
|
|
2491
|
+
}, [pendingUndo, pendingClear])
|
|
2492
|
+
|
|
2493
|
+
const showError = (text) => {
|
|
2494
|
+
setNotice(null)
|
|
2495
|
+
setErrorNotice(text)
|
|
2496
|
+
}
|
|
2497
|
+
const showNotice = (text) => {
|
|
2498
|
+
setErrorNotice(null)
|
|
2499
|
+
setNotice(text)
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// 保留条数的就地保存(定义放在 showError / showNotice 之后:它们是被引用的
|
|
2503
|
+
// 绑定,写在前面会在首次渲染时落进 TDZ —— 本文件踩过同一个坑)。
|
|
2504
|
+
const changeRetention = async (next) => {
|
|
2505
|
+
if (retentionPending || next === savedRetention) return
|
|
2506
|
+
setRetentionPending(true)
|
|
2507
|
+
setErrorNotice(null)
|
|
2508
|
+
try {
|
|
2509
|
+
// 只发这一个字段:服务端把 POST 体当 patch 用(`{ ...current, ...patch }`),
|
|
2510
|
+
// 不必回传整份配置 —— 也就不会把别的标签页刚改的字段覆盖回旧值。
|
|
2511
|
+
const payload = await controller.saveConfig({ historyMaxEntries: next })
|
|
2512
|
+
if (!payload) {
|
|
2513
|
+
showError(t('toast.saveFailed'))
|
|
2514
|
+
return
|
|
2515
|
+
}
|
|
2516
|
+
// 服务端会夹取(clampHistoryMaxEntries):回读它**实际接受**的值,
|
|
2517
|
+
// 而不是相信我们发出去的那个。
|
|
2518
|
+
const applied = Number.isFinite(payload.config && payload.config.historyMaxEntries)
|
|
2519
|
+
? payload.config.historyMaxEntries
|
|
2520
|
+
: savedRetention
|
|
2521
|
+
showNotice(t('history.maxEntriesSaved', { count: String(applied) }))
|
|
2522
|
+
// 窗口可能变小(例如 200 → 50):顺手把列表刷新回新窗口,免得界面上
|
|
2523
|
+
// 还挂着按旧上限读来的条目。
|
|
2524
|
+
await controller.loadHistory()
|
|
2525
|
+
} catch (err) {
|
|
2526
|
+
showError(messageOf(err))
|
|
2527
|
+
} finally {
|
|
2528
|
+
setRetentionPending(false)
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
const handleUndo = async (id) => {
|
|
2533
|
+
if (pendingUndo !== id) {
|
|
2534
|
+
setPendingUndo(id)
|
|
2535
|
+
return
|
|
2536
|
+
}
|
|
2537
|
+
setPendingUndo(null)
|
|
2538
|
+
try {
|
|
2539
|
+
const payload = await controller.rollbackHistory(id)
|
|
2540
|
+
await controller.loadHistory()
|
|
2541
|
+
const count = Array.isArray(payload && payload.reversed) ? payload.reversed.length : 0
|
|
2542
|
+
if (count === 0) {
|
|
2543
|
+
showNotice(t('history.restoreUnchanged'))
|
|
2544
|
+
} else {
|
|
2545
|
+
showNotice(t('history.restoreDone', { count: String(count) }))
|
|
2546
|
+
}
|
|
2547
|
+
} catch (err) {
|
|
2548
|
+
showError(messageOf(err))
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
const handleRestorePreset = async (id) => {
|
|
2553
|
+
try {
|
|
2554
|
+
const payload = await controller.restorePersona(id)
|
|
2555
|
+
await controller.loadHistory()
|
|
2556
|
+
const restored = Array.isArray(payload && payload.restored) ? payload.restored : []
|
|
2557
|
+
const skipped = Array.isArray(payload && payload.skipped) ? payload.skipped : []
|
|
2558
|
+
const names = restored.join(t('common.listSep'))
|
|
2559
|
+
showNotice(
|
|
2560
|
+
skipped.length > 0
|
|
2561
|
+
? `${t('history.restorePresetDone', { name: names })} ${t('history.restoreSkippedNote', { count: String(restored.length), skipped: String(skipped.length) })}`
|
|
2562
|
+
: t('history.restorePresetDone', { name: names })
|
|
2563
|
+
)
|
|
2564
|
+
} catch (err) {
|
|
2565
|
+
showError(messageOf(err))
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
const handleClearHistory = async () => {
|
|
2570
|
+
if (!pendingClear) {
|
|
2571
|
+
setPendingClear(true)
|
|
2572
|
+
return
|
|
2573
|
+
}
|
|
2574
|
+
setPendingClear(false)
|
|
2575
|
+
try {
|
|
2576
|
+
const payload = await controller.clearHistory()
|
|
2577
|
+
await controller.loadHistory()
|
|
2578
|
+
const removed = payload && typeof payload.removed === 'number' ? payload.removed : 0
|
|
2579
|
+
showNotice(t('history.clearDone', { count: String(removed) }))
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
showError(messageOf(err))
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// 挂载即拉取:详情页本来就是「专门来看记录」才打开的页面。
|
|
2586
|
+
React.useEffect(() => {
|
|
2587
|
+
if (!controller.canRefresh()) return
|
|
2588
|
+
void controller.loadHistory()
|
|
2589
|
+
}, [controller])
|
|
2590
|
+
|
|
2591
|
+
// —— 渲染辅助(与 0.8.0-d 同源,搬成组件内局部函数)——
|
|
2592
|
+
const historySummary = historyError
|
|
2593
|
+
? t('accordion.loadFailed')
|
|
2594
|
+
: (history === null
|
|
2595
|
+
? t('status.loading')
|
|
2596
|
+
: (history.length === 0
|
|
2597
|
+
? t('accordion.empty')
|
|
2598
|
+
: (Number.isFinite(historyMax)
|
|
2599
|
+
? t('history.summary', { count: String(history.length), max: String(historyMax) })
|
|
2600
|
+
: t('history.summaryCount', { count: String(history.length) }))))
|
|
2601
|
+
|
|
2602
|
+
const historyTimeText = (rawAt) => {
|
|
2603
|
+
const at = Date.parse(rawAt)
|
|
2604
|
+
if (!Number.isFinite(at)) return String(rawAt)
|
|
2605
|
+
const diff = Date.now() - at
|
|
2606
|
+
// 时钟回拨(或服务端时间略快)会让 diff 变成负数 —— 当成「刚刚」。
|
|
2607
|
+
if (diff < 60 * 1000) return t('history.timeJustNow')
|
|
2608
|
+
const minutes = Math.floor(diff / (60 * 1000))
|
|
2609
|
+
if (minutes < 60) return t('history.timeMinutes', { count: String(minutes) })
|
|
2610
|
+
const hours = Math.floor(minutes / 60)
|
|
2611
|
+
if (hours < 24) return t('history.timeHours', { count: String(hours) })
|
|
2612
|
+
return t('history.timeDays', { count: String(Math.floor(hours / 24)) })
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
const historySourceText = (source) => {
|
|
2616
|
+
const key = `history.source.${String(source)}`
|
|
2617
|
+
return HISTORY_SOURCES.includes(source) ? t(key) : String(source)
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
const historyValueText = (key, value) => {
|
|
2621
|
+
if (value === undefined || value === null || value === '') return t('history.valueEmpty')
|
|
2622
|
+
if (typeof value === 'boolean') return t(value ? 'accordion.on' : 'accordion.off')
|
|
2623
|
+
if (key === 'language') {
|
|
2624
|
+
const hit = LANGUAGE_OPTIONS.find((item) => item.value === value)
|
|
2625
|
+
return hit ? hit.label : String(value)
|
|
2626
|
+
}
|
|
2627
|
+
// 只有落在合法取值里才查词典:否则 `style: 'custom-whatever'` 会渲染成键名本身
|
|
2628
|
+
const toKey = HISTORY_ENUM_KEYS[key]
|
|
2629
|
+
if (toKey && HISTORY_ENUM_VALUES[key].includes(value)) return t(toKey(value))
|
|
2630
|
+
return String(value)
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
const historyFieldNames = (entry) => {
|
|
2634
|
+
const keys = new Set()
|
|
2635
|
+
for (const key of Object.keys(entry.before || {})) keys.add(key)
|
|
2636
|
+
for (const key of Object.keys(entry.after || {})) keys.add(key)
|
|
2637
|
+
return [...keys]
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
const historyPersonaEvents = (entry) => {
|
|
2641
|
+
const events = (entry && entry.personas) || {}
|
|
2642
|
+
const added = Array.isArray(events.added) ? events.added : []
|
|
2643
|
+
const removed = Array.isArray(events.removed) ? events.removed.map((item) => item && item.name).filter(Boolean) : []
|
|
2644
|
+
const updated = Array.isArray(events.updated) ? events.updated.map((item) => item && item.name).filter(Boolean) : []
|
|
2645
|
+
return { added, removed, updated }
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
const historyRowText = (entry) => {
|
|
2649
|
+
const parts = historyFieldNames(entry).map((key) => t(HISTORY_FIELD_LABELS[key] || key))
|
|
2650
|
+
const events = historyPersonaEvents(entry)
|
|
2651
|
+
const sep = t('common.listSep')
|
|
2652
|
+
if (events.added.length > 0) parts.push(t('history.personasAdded', { names: events.added.join(sep) }))
|
|
2653
|
+
if (events.removed.length > 0) parts.push(t('history.personasRemoved', { names: events.removed.join(sep) }))
|
|
2654
|
+
if (events.updated.length > 0) parts.push(t('history.personasUpdated', { names: events.updated.join(sep) }))
|
|
2655
|
+
// label(如预设名)紧跟在第一个字段前说出来:「应用预设:苏格拉底式提问者 · 自定义指令」
|
|
2656
|
+
// —— 没有它,这一行答不了「应用的是什么」。旧记录没有 label,原样省略。
|
|
2657
|
+
if (typeof entry.label === 'string' && entry.label !== '') parts.unshift(t('history.labelSuffix', { label: entry.label }))
|
|
2658
|
+
return parts.length > 0 ? t('history.fields', { fields: parts.join(sep) }) : t('history.emptyFields')
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
const historyFieldRows = (entry) => {
|
|
2662
|
+
const before = (entry && entry.before) || {}
|
|
2663
|
+
const after = (entry && entry.after) || {}
|
|
2664
|
+
const keys = [...new Set([...Object.keys(after), ...Object.keys(before)])]
|
|
2665
|
+
return keys.map((key) => ({
|
|
2666
|
+
key,
|
|
2667
|
+
label: t(HISTORY_FIELD_LABELS[key] || key),
|
|
2668
|
+
from: historyValueText(key, before[key]),
|
|
2669
|
+
to: historyValueText(key, after[key])
|
|
2670
|
+
}))
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
// —— 外壳对齐宿主插件详情页(fO69Vq_ 前缀的样式)——
|
|
2674
|
+
// .detailSection:flex column、gap:12px;.sectionHead:baseline 对齐的标题行;
|
|
2675
|
+
// .sectionTitle:14px/500/20px;.sectionCount:12px 次要色。
|
|
2676
|
+
return e('section', { className: 'soul-detail-history', 'data-soul-history': 'true' },
|
|
2677
|
+
e('div', { className: 'soul-detail-history-head' },
|
|
2678
|
+
e('h4', { className: 'soul-detail-history-title' }, t('group.history')),
|
|
2679
|
+
e('span', { className: 'soul-detail-history-count' }, historySummary)
|
|
2680
|
+
),
|
|
2681
|
+
// —— 保留条数:与它约束的内容放在一起 ——
|
|
2682
|
+
//
|
|
2683
|
+
// 标题行说「最近 37 条 · 最多保留 200 条」,这个 200 就地可改,不必去设置页找。
|
|
2684
|
+
// 放在列表**之前**而不是之后:它是这个区块的容量设置,读了标题就会想知道它在哪;
|
|
2685
|
+
// 放到几十条记录下面,等于让用户先翻完才看见。
|
|
2686
|
+
// 记录为空 / 读取失败时也照常渲染 —— 容量参数与「当前有几条」无关。
|
|
2687
|
+
e('div', { className: 'soul-history-retention' },
|
|
2688
|
+
e('label', { htmlFor: 'soul-historyMaxEntries' }, t('history.maxEntries')),
|
|
2689
|
+
e('select', {
|
|
2690
|
+
id: 'soul-historyMaxEntries',
|
|
2691
|
+
value: String(savedRetention),
|
|
2692
|
+
disabled: retentionPending,
|
|
2693
|
+
onChange: (ev) => { void changeRetention(Number(ev.target.value)) }
|
|
2694
|
+
},
|
|
2695
|
+
...HISTORY_MAX_ENTRIES_OPTIONS.map((count) => e('option', {
|
|
2696
|
+
key: count,
|
|
2697
|
+
value: String(count)
|
|
2698
|
+
}, count === HISTORY_MAX_ENTRIES_DEFAULT
|
|
2699
|
+
? t('history.maxEntriesDefault', { count: String(count) })
|
|
2700
|
+
: t('history.maxEntriesOption', { count: String(count) })))
|
|
2701
|
+
),
|
|
2702
|
+
e(SoulHint, { text: t('history.maxEntriesHint') })
|
|
2703
|
+
),
|
|
2704
|
+
// 结果提示:成功绿字 / 失败红字,与设置页同一套形态(宿主 savedNotice 语义)
|
|
2705
|
+
(notice || errorNotice) && (errorNotice
|
|
2706
|
+
? e('p', { className: 'soul-error-notice', role: 'alert' }, errorNotice)
|
|
2707
|
+
: e('p', { className: 'soul-saved-notice', role: 'status', 'aria-live': 'polite' }, notice)),
|
|
2708
|
+
historyError
|
|
2709
|
+
? e('div', { className: 'soul-error' }, t('history.loadFailed', { reason: historyError }))
|
|
2710
|
+
: (history === null
|
|
2711
|
+
? e('div', { className: 'soul-status' }, t('history.loading'))
|
|
2712
|
+
: (history.length === 0
|
|
2713
|
+
? e('div', { className: 'soul-status' }, t('history.empty'))
|
|
2714
|
+
: e(Fragment, null,
|
|
2715
|
+
// 清空是**不可逆**的(记录里有昵称、职业、自述、自定义指令的原文),
|
|
2716
|
+
// 所以它是这一组唯一的危险色按钮,且同样要过二次确认。
|
|
2717
|
+
e('div', { className: 'soul-history-head' },
|
|
2718
|
+
e('button', {
|
|
2719
|
+
type: 'button',
|
|
2720
|
+
className: `soul-history-clear${pendingClear ? ' soul-history-clear-armed' : ''}`,
|
|
2721
|
+
onClick: handleClearHistory
|
|
2722
|
+
}, t(pendingClear ? 'history.clearArmed' : 'history.clear'))
|
|
2723
|
+
),
|
|
2724
|
+
|
|
2725
|
+
// 记录可能不完整(末尾被截断 / 文件过大只读了一段 / 有坏行)——
|
|
2726
|
+
// 这是「看到的不是全部」,不是失败。
|
|
2727
|
+
historyWarning && e('div', { className: 'soul-status soul-history-warning' },
|
|
2728
|
+
t('history.warning', { reason: historyWarning })),
|
|
2729
|
+
|
|
2730
|
+
e('ul', { className: 'soul-history-list' },
|
|
2731
|
+
...pageEntries.map((entry) => {
|
|
2732
|
+
const isOpen = expandedEntry === entry.id
|
|
2733
|
+
const events = historyPersonaEvents(entry)
|
|
2734
|
+
const rows = historyFieldRows(entry)
|
|
2735
|
+
const arming = pendingUndo === entry.id
|
|
2736
|
+
return e('li', {
|
|
2737
|
+
key: entry.id,
|
|
2738
|
+
className: 'soul-history-item',
|
|
2739
|
+
'data-source': String(entry.source)
|
|
2740
|
+
},
|
|
2741
|
+
e('div', { className: 'soul-history-item-head' },
|
|
2742
|
+
// 整行主体是一个按钮(展开/收起),操作按钮放在它**外面** ——
|
|
2743
|
+
// 按钮里套按钮既非法又会让点「撤销」连带展开。
|
|
2744
|
+
e('button', {
|
|
2745
|
+
type: 'button',
|
|
2746
|
+
className: 'soul-history-item-main',
|
|
2747
|
+
'aria-expanded': isOpen,
|
|
2748
|
+
onClick: () => setExpandedEntry(isOpen ? null : entry.id)
|
|
2749
|
+
},
|
|
2750
|
+
e('span', { className: 'soul-history-time' }, historyTimeText(entry.at)),
|
|
2751
|
+
e('span', { className: 'soul-history-source' }, historySourceText(entry.source)),
|
|
2752
|
+
e('span', { className: 'soul-history-fields' }, historyRowText(entry))
|
|
2753
|
+
),
|
|
2754
|
+
e('div', { className: 'soul-history-item-actions' },
|
|
2755
|
+
// 撤销用次要色:它可逆;真正不可逆的是清空,危险色留给它。
|
|
2756
|
+
e('button', {
|
|
2757
|
+
type: 'button',
|
|
2758
|
+
className: `soul-history-action${arming ? ' soul-history-action-armed' : ''}`,
|
|
2759
|
+
onClick: () => handleUndo(entry.id)
|
|
2760
|
+
}, t(arming ? 'history.restoreArmed' : 'history.restore')),
|
|
2761
|
+
// 只有「删过预设」的记录才给恢复入口 —— 其余记录点它无事可做。
|
|
2762
|
+
events.removed.length > 0 && e('button', {
|
|
2763
|
+
type: 'button',
|
|
2764
|
+
className: 'soul-history-action',
|
|
2765
|
+
onClick: () => handleRestorePreset(entry.id)
|
|
2766
|
+
}, t('history.restorePreset'))
|
|
2767
|
+
)
|
|
2768
|
+
),
|
|
2769
|
+
|
|
2770
|
+
isOpen && e('div', { className: 'soul-history-detail' },
|
|
2771
|
+
rows.length > 0 && e('div', { className: 'soul-history-diff' },
|
|
2772
|
+
// 「展开即全文」:原值/新值不再压成单行省略 —— 自定义指令几百字时,
|
|
2773
|
+
// 一行省略号等于没给。每行竖排:字段名 + 原值块 + 新值块,长文本换行铺开。
|
|
2774
|
+
...rows.map((row) => e('div', { key: row.key, className: 'soul-history-diff-row' },
|
|
2775
|
+
e('span', { className: 'soul-history-diff-field' }, row.label),
|
|
2776
|
+
row.from !== '' && e('div', { className: 'soul-history-diff-cell' },
|
|
2777
|
+
e('span', { className: 'soul-history-diff-tag' }, t('history.field.from')),
|
|
2778
|
+
e('div', { className: 'soul-history-diff-value' }, row.from)
|
|
2779
|
+
),
|
|
2780
|
+
e('div', { className: 'soul-history-diff-cell' },
|
|
2781
|
+
e('span', { className: 'soul-history-diff-tag' }, t('history.field.to')),
|
|
2782
|
+
e('div', { className: 'soul-history-diff-value' }, row.to)
|
|
2783
|
+
)
|
|
2784
|
+
))
|
|
2785
|
+
),
|
|
2786
|
+
events.added.length > 0 && e('div', { className: 'soul-history-diff-persona' },
|
|
2787
|
+
t('history.personasAdded', { names: events.added.join(t('common.listSep')) })),
|
|
2788
|
+
events.updated.length > 0 && e('div', { className: 'soul-history-diff-persona' },
|
|
2789
|
+
t('history.personasUpdated', { names: events.updated.join(t('common.listSep')) })),
|
|
2790
|
+
events.removed.length > 0 && e('div', { className: 'soul-history-diff-persona' },
|
|
2791
|
+
t('history.personasRemoved', { names: events.removed.join(t('common.listSep')) }))
|
|
2792
|
+
)
|
|
2793
|
+
)
|
|
2794
|
+
})
|
|
2795
|
+
),
|
|
2796
|
+
|
|
2797
|
+
// 分页器(0.9.0-ai):只在多于一页时出现。
|
|
2798
|
+
// 按钮直接用宿主 ui.Button,与工作区绑定按钮同一套外观(不覆盖底色/文字)。
|
|
2799
|
+
historyPageCount > 1 && e('div', { className: 'soul-history-pager' },
|
|
2800
|
+
e(ui.Button, {
|
|
2801
|
+
disabled: historyPageIndex <= 0,
|
|
2802
|
+
onClick: () => setHistoryPage(historyPageIndex - 1)
|
|
2803
|
+
}, t('history.prev')),
|
|
2804
|
+
e('span', { className: 'soul-history-pageinfo' },
|
|
2805
|
+
t('history.pageInfo', {
|
|
2806
|
+
page: String(historyPageIndex + 1),
|
|
2807
|
+
total: String(historyPageCount),
|
|
2808
|
+
count: String(historyEntries.length)
|
|
2809
|
+
})),
|
|
2810
|
+
e(ui.Button, {
|
|
2811
|
+
disabled: historyPageIndex >= historyPageCount - 1,
|
|
2812
|
+
onClick: () => setHistoryPage(historyPageIndex + 1)
|
|
2813
|
+
}, t('history.next'))
|
|
2814
|
+
),
|
|
2815
|
+
|
|
2816
|
+
e('div', { className: 'soul-status' }, t('history.footer'))
|
|
2817
|
+
)))
|
|
2818
|
+
)
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// -------------------------------------------------------------------------
|
|
2822
|
+
// 会话级人设(0.9.0-d)—— 注册到宿主的 `conversation.view` 列表槽位
|
|
2823
|
+
// -------------------------------------------------------------------------
|
|
2824
|
+
//
|
|
2825
|
+
// 为什么在这里而不在设置页:会话覆盖**只对这个会话**有意义,而设置页的槽位
|
|
2826
|
+
// scope 是 `root`,拿不到 sessionId(宿主契约:settings.section 不注入会话)。
|
|
2827
|
+
// `conversation.view` 的 inject 回调是 `(sessionId) => props`,这是插件能拿到
|
|
2828
|
+
// 当前会话 id 的唯一正规入口。
|
|
2829
|
+
//
|
|
2830
|
+
// 与设置页的第二个区别:这里的改动**即时生效**,没有"保存"按钮 ——
|
|
2831
|
+
// 用户在对话中途想换个风格,不该先跑去设置页。
|
|
2832
|
+
function SoulSessionPersona(props) {
|
|
2833
|
+
const { controller, useSoulController } = props
|
|
2834
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
2835
|
+
// 宿主把当前会话 id 通过槽位 inject 传进来(conversation.view 的约定)。
|
|
2836
|
+
const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : null
|
|
2837
|
+
// 全局 store:预设列表、配置都在这里(与设置页同一份)。
|
|
2838
|
+
const state = useSoulController((state) => state)
|
|
2839
|
+
|
|
2840
|
+
// 本会话的解析结果**独立于全局 store**:一份覆盖只对它所属的会话有意义,
|
|
2841
|
+
// 塞进全局 store 会让两个会话的 tab 互相串味。
|
|
2842
|
+
const [localView, setView] = React.useState(null)
|
|
2843
|
+
// 会话作用域解析结果 —— **优先读全局 store**,回落本地 state。
|
|
2844
|
+
//
|
|
2845
|
+
// 为什么:header 的人设标签与这个 tab 是两个组件,都要显示「当前生效的人设 + 来源」。
|
|
2846
|
+
// 两边各存一份必然漂移(实测:tab 里切换了人设,header 标签仍显示旧的 ——
|
|
2847
|
+
// 因为标签只在 sessionId 变化时取数,而切换人设不改变 sessionId)。
|
|
2848
|
+
// store 是单一数据源:saveSessionScope 成功后写入,两边一起刷新。
|
|
2849
|
+
const storedScope = state.sessionScopes && state.sessionScopes[String(sessionId)]
|
|
2850
|
+
const view = storedScope || localView
|
|
2851
|
+
const [error, setError] = React.useState(null)
|
|
2852
|
+
const [notice, setNotice] = React.useState(null)
|
|
2853
|
+
const [busy, setBusy] = React.useState(false)
|
|
2854
|
+
// 右栏详情正在展示哪一张卡(null = 还没选中)。
|
|
2855
|
+
//
|
|
2856
|
+
// 与「生效」是两件事:这个纯属浏览状态,点卡片只换详情、不改配置;
|
|
2857
|
+
// 真正写配置的是详情面板里的「用这个」。初值在下面的 effect 里跟随生效项。
|
|
2858
|
+
const [selectedPersona, setSelectedPersona] = React.useState(null)
|
|
2859
|
+
|
|
2860
|
+
// 刻意**不用** React.useCallback:本项目的客户端垫片(scripts/verify-client.mjs)
|
|
2861
|
+
// 只实现了 useState / useEffect / useRef —— 用 useCallback 会让整条客户端回归
|
|
2862
|
+
// 在这个组件上一头撞死(历史上已经踩过一次)。这里的依赖只有 sessionId,
|
|
2863
|
+
// 用普通函数 + 显式依赖数组完全等价,而且垫片也能跑。
|
|
2864
|
+
const reload = async () => {
|
|
2865
|
+
if (sessionId === null) {
|
|
2866
|
+
setView(null)
|
|
2867
|
+
setError(null)
|
|
2868
|
+
return
|
|
2869
|
+
}
|
|
2870
|
+
try {
|
|
2871
|
+
const payload = await controller.fetchSessionScope(sessionId)
|
|
2872
|
+
setView(payload)
|
|
2873
|
+
setError(null)
|
|
2874
|
+
} catch (err) {
|
|
2875
|
+
setError(messageOf(err))
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// 切到别的会话时重新取:这个组件的 props.sessionId 会随宿主切换而变。
|
|
2880
|
+
React.useEffect(() => {
|
|
2881
|
+
setNotice(null)
|
|
2882
|
+
// 切会话时清掉选中态:上一屏看的那张卡与这个会话无关,留着会让人以为它在生效
|
|
2883
|
+
setSelectedPersona(null)
|
|
2884
|
+
void reload()
|
|
2885
|
+
// reload 的闭包只捕获 sessionId(其余是稳定的 setter / controller)
|
|
2886
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2887
|
+
}, [sessionId])
|
|
2888
|
+
|
|
2889
|
+
// 预设名列表与设置页同一套排序(内置优先),避免两处顺序不一致。
|
|
2890
|
+
const personaNames = state.personas
|
|
2891
|
+
? Object.keys(state.personas).sort((a, b) => {
|
|
2892
|
+
const aBuiltin = state.personas[a] && state.personas[a].builtin ? 0 : 1
|
|
2893
|
+
const bBuiltin = state.personas[b] && state.personas[b].builtin ? 0 : 1
|
|
2894
|
+
return aBuiltin - bBuiltin
|
|
2895
|
+
})
|
|
2896
|
+
: []
|
|
2897
|
+
|
|
2898
|
+
const apply = async (personaName) => {
|
|
2899
|
+
if (sessionId === null || busy) return
|
|
2900
|
+
const entry = state.personas ? state.personas[personaName] : null
|
|
2901
|
+
if (!entry) return
|
|
2902
|
+
setBusy(true)
|
|
2903
|
+
setError(null)
|
|
2904
|
+
try {
|
|
2905
|
+
// 只提交该预设**声明过**的字段(部分覆盖),与「应用预设」「工作区绑定」
|
|
2906
|
+
// 走同一条语义 —— 否则三处效果不一致,用户没法用任一处验证另一处。
|
|
2907
|
+
const overrides = {}
|
|
2908
|
+
for (const key of SESSION_OVERRIDE_KEYS) {
|
|
2909
|
+
if (key in entry) overrides[key] = entry[key]
|
|
2910
|
+
}
|
|
2911
|
+
// 带上**用户实际点的预设名**:服务端要靠它显示"当前生效的是哪个预设"。
|
|
2912
|
+
//
|
|
2913
|
+
// 只传字段是不够的 —— 覆盖项里没有名字,服务端只能拿当前配置去反查哪个预设
|
|
2914
|
+
// 匹配得上。两个预设声明了相同字段时,反查结果取决于**名称排序**:
|
|
2915
|
+
// 实测用户自建「游戏策划与开发专家」与内置「自驱型协作者」字段一致时,
|
|
2916
|
+
// 点了后者却显示前者。把名字一起传过去,反查就退化成兜底而不是唯一依据。
|
|
2917
|
+
const payload = await controller.saveSessionScope(sessionId, overrides, personaName)
|
|
2918
|
+
setView(payload)
|
|
2919
|
+
setNotice(t('session.applied', { name: personaName }))
|
|
2920
|
+
} catch (err) {
|
|
2921
|
+
setError(messageOf(err))
|
|
2922
|
+
} finally {
|
|
2923
|
+
setBusy(false)
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
const clear = async () => {
|
|
2928
|
+
if (sessionId === null || busy) return
|
|
2929
|
+
setBusy(true)
|
|
2930
|
+
setError(null)
|
|
2931
|
+
try {
|
|
2932
|
+
const payload = await controller.clearSessionScope(sessionId)
|
|
2933
|
+
setView(payload)
|
|
2934
|
+
setNotice(t('session.cleared'))
|
|
2935
|
+
} catch (err) {
|
|
2936
|
+
setError(messageOf(err))
|
|
2937
|
+
} finally {
|
|
2938
|
+
setBusy(false)
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
// ⚠️ 所有 hook 必须声明在**任何提前 return 之前**。
|
|
2943
|
+
//
|
|
2944
|
+
// 这个 effect 原先写在 `if (view === null) return ...` 后面,于是:
|
|
2945
|
+
// 首屏 view === null → 提前 return,effect 没执行(hook 数 = 5)
|
|
2946
|
+
// view 到达后 → effect 执行(hook 数 = 6)
|
|
2947
|
+
// React 检测到「本次渲染的 hook 比上次多」⇒ error #310
|
|
2948
|
+
// (Rendered more hooks than during the previous render)整块 tab 崩溃。
|
|
2949
|
+
//
|
|
2950
|
+
// 测试台没抓到,是因为它每次 settle 都走完整的渲染循环、且组件从不经过
|
|
2951
|
+
// 「先 null 后有值」的两段式挂载。**这是垫片与真实 React 的差异,不是产品逻辑
|
|
2952
|
+
// 正确性的证据** —— 教训:hook 顺序在垫片里测不出来,只能靠代码纪律。
|
|
2953
|
+
React.useEffect(() => {
|
|
2954
|
+
if (selectedPersona !== null) return
|
|
2955
|
+
// 优先跟随「当前生效」的预设。
|
|
2956
|
+
if (view && typeof view.activePersona === 'string' && view.activePersona !== '') {
|
|
2957
|
+
setSelectedPersona(view.activePersona)
|
|
2958
|
+
return
|
|
2959
|
+
}
|
|
2960
|
+
// ⚠️ view 还没加载完时**必须等**:此刻 view 为 null、activePersona 也读不到,
|
|
2961
|
+
// 若在这里兜底就会抢先选中第一个预设;等 view 到达后,
|
|
2962
|
+
// 「selectedPersona !== null」的守卫又会把正确的选择挡回去 ——
|
|
2963
|
+
// 结果是「生效的那张被甩到侧边、中心卡是别的预设」。
|
|
2964
|
+
if (!view) return
|
|
2965
|
+
// 兜底:全局配置**不匹配任何预设**时(手改过特质、纯自定义配置),
|
|
2966
|
+
// 服务端回的 activePersona 是 null。此时若不兜底,选中态永远为 null ⇒
|
|
2967
|
+
// 右栏渲染成「点左侧任意一张卡」的空提示 ⇒ **一个按钮都没有**,
|
|
2968
|
+
// 用户看到的是「点了没反应」(其实连按钮都没画出来)。
|
|
2969
|
+
// 兜底到第一个可用预设,右栏立刻有内容与「用这个」按钮。
|
|
2970
|
+
if (personaNames.length > 0) setSelectedPersona(personaNames[0])
|
|
2971
|
+
// selectedPersona 是「要不要跟随」的判据,必须进依赖;view / personaNames
|
|
2972
|
+
// 变化时重新评估。
|
|
2973
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2974
|
+
}, [view, selectedPersona, personaNames])
|
|
2975
|
+
|
|
2976
|
+
// 会话 id 缺席(宿主在诊断 / 空白会话时可能不传):如实说明,不假装能改。
|
|
2977
|
+
if (sessionId === null) {
|
|
2978
|
+
return e('div', { className: 'soul-session-persona' },
|
|
2979
|
+
e('div', { className: 'soul-status' }, t('session.workspaceUnknown'))
|
|
2980
|
+
)
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
if (view === null) {
|
|
2984
|
+
return e('div', { className: 'soul-session-persona' },
|
|
2985
|
+
error
|
|
2986
|
+
? e('div', { className: 'soul-error' }, t('session.loadFailed', { reason: error }))
|
|
2987
|
+
: e('div', { className: 'soul-status' }, t('session.loading'))
|
|
2988
|
+
)
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
// 「来源」必须说清是**哪一层**决定的:否则用户看到一个人设名,却不知道该去哪儿改它。
|
|
2992
|
+
const sourceText = (() => {
|
|
2993
|
+
if (view.source === 'session') return t('session.source.session')
|
|
2994
|
+
if (view.source === 'workspace') {
|
|
2995
|
+
return t('session.source.workspace', { name: view.workspaceTitle || view.workspaceId || '' })
|
|
2996
|
+
}
|
|
2997
|
+
if (view.source === 'directory') return t('session.source.directory')
|
|
2998
|
+
return t('session.source.global')
|
|
2999
|
+
})()
|
|
3000
|
+
|
|
3001
|
+
const hasOverride = view.override !== null && view.override !== undefined
|
|
3002
|
+
const activePersona = typeof view.activePersona === 'string' && view.activePersona !== ''
|
|
3003
|
+
? view.activePersona
|
|
3004
|
+
: t('session.noPersona')
|
|
3005
|
+
|
|
3006
|
+
// —— 卡牌网格 ——
|
|
3007
|
+
//
|
|
3008
|
+
// 与设置页的「人设预设」用**同一套卡牌语言**(同样的圆角/描边/插画/压图标题),
|
|
3009
|
+
// 理由不是"顺手复用":用户在设置页学会「一张卡 = 一个人设,点卡面上的按钮就用它」,
|
|
3010
|
+
// 到了会话里面对同一个概念(人设),切换成本最低的方式就是长成一样。
|
|
3011
|
+
// —— 左:卡牌轮转(叠层)/右:选中卡的详情 ——
|
|
3012
|
+
//
|
|
3013
|
+
// 轮转而非平铺:会话面板横向空间本来就窄,平铺会把每张卡挤到「名字 + 一句效果」
|
|
3014
|
+
// 看不出差异;叠层轮转与设置页的人设卡牌**同构**,用户在设置页学会的
|
|
3015
|
+
// 「左右露边 = 可翻,中心大卡 = 正在看」在这里直接复用。
|
|
3016
|
+
//
|
|
3017
|
+
// 与设置页轮转的两点差异(会话面板高度有限,简化几何):
|
|
3018
|
+
// · 偏移步长收窄到 68px / 0.94 / 0.88(设置页 92px / 0.9 / 0.8)—— 三张同屏
|
|
3019
|
+
// 时总宽约 260px,正好卡进左栏;
|
|
3020
|
+
// · 不做 3D 翻面(右栏详情已承担「看清」,翻面会与它重复)。
|
|
3021
|
+
//
|
|
3022
|
+
// 选中态与生效态是**两个不同的东西**,必须分开表达:
|
|
3023
|
+
// · selectedPersona = 中心大卡(右栏详情跟着变,纯浏览,不影响配置);
|
|
3024
|
+
// · isEffective = 这一张是否正在生效(配置事实,名字前加 ★)。
|
|
3025
|
+
const cardFor = (name, offset) => {
|
|
3026
|
+
const entry = state.personas ? state.personas[name] : null
|
|
3027
|
+
const builtin = !!(entry && entry.builtin)
|
|
3028
|
+
const isEffective = hasOverride && activePersona === name
|
|
3029
|
+
return e('div', {
|
|
3030
|
+
key: name,
|
|
3031
|
+
className: 'soul-session-card',
|
|
3032
|
+
'data-effective': isEffective ? 'true' : 'false',
|
|
3033
|
+
'data-current': offset === 0 ? 'true' : 'false',
|
|
3034
|
+
'aria-hidden': offset === 0 ? undefined : 'true',
|
|
3035
|
+
// 偏移归**叠层**所有(内联 transform);与 CSS 里 hover/焦点态的
|
|
3036
|
+
// translateY 微抬无关 —— 两者若合成一个 transform 会互相打架。
|
|
3037
|
+
style: {
|
|
3038
|
+
transform: `translateX(${offset * 78}px) scale(${1 - Math.abs(offset) * 0.06})`,
|
|
3039
|
+
zIndex: String(10 - Math.abs(offset)),
|
|
3040
|
+
opacity: offset === 0 ? '1' : '0.55'
|
|
3041
|
+
},
|
|
3042
|
+
// 0.9.0-m:卡牌**不再可点** —— 切换改由下方轮播导航(左右按钮 + 圆点)负责,
|
|
3043
|
+
// 与设置页的「人设预设」轮播同一套交互。卡上只保留信息与状态角标。
|
|
3044
|
+
//
|
|
3045
|
+
// 无障碍名仍然要:卡面已无可见文字(0.9.0-l 移除了遮罩与压图标题),
|
|
3046
|
+
// 纯图形元素必须有可访问名,读屏用户才知道这是哪张卡。
|
|
3047
|
+
'aria-label': name,
|
|
3048
|
+
title: isEffective ? `${name}(${t('session.chipInUse')})` : name
|
|
3049
|
+
},
|
|
3050
|
+
e('div', { className: 'soul-persona-art' },
|
|
3051
|
+
e('img', {
|
|
3052
|
+
className: 'soul-persona-art-img',
|
|
3053
|
+
src: new URL(`/api/soul/persona-art/${encodeURIComponent(name)}`, hostBase()).href,
|
|
3054
|
+
alt: '',
|
|
3055
|
+
loading: 'lazy',
|
|
3056
|
+
decoding: 'async',
|
|
3057
|
+
draggable: 'false',
|
|
3058
|
+
onError: (ev) => { ev.target.style.display = 'none' }
|
|
3059
|
+
}),
|
|
3060
|
+
// 遮罩与压图文字**已移除**(0.9.0-l):右侧详情已完整展示人设名、
|
|
3061
|
+
// 六个维度与自定义指令全文,卡上再叠一层渐变遮罩 + 文字只是重复,
|
|
3062
|
+
// 而且遮罩会让插画本身失掉大半饱和度 —— 既然是「完全展示卡牌」,
|
|
3063
|
+
// 就让插画原样呈现。
|
|
3064
|
+
//
|
|
3065
|
+
// 保留的是**功能性角标**(不是文字信息,右栏没有的):
|
|
3066
|
+
// · 内置徽标 —— 这张卡是内置的还是自建的
|
|
3067
|
+
// · 位置指示 i / n —— 排第几张(只标中心卡,否则三张都显示同一个数)
|
|
3068
|
+
// · ★ —— 这张正在生效(原本在名字前,名字没了就挪到右上角)
|
|
3069
|
+
builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin')),
|
|
3070
|
+
offset === 0 && e('span', { className: 'soul-persona-card-pos' }, `${personaIndexOf(name) + 1} / ${personaNames.length}`),
|
|
3071
|
+
isEffective && e('span', { className: 'soul-session-card-star' }, '★')
|
|
3072
|
+
)
|
|
3073
|
+
// 下方不再有文字区(0.9.0-j):右栏详情已完整展示人设名、六个维度、
|
|
3074
|
+
// 自定义指令全文与效果说明,卡上重复一遍是冗余,还会把插画挤成半张。
|
|
3075
|
+
// 「完全展示卡牌」= 插画铺满整张卡。
|
|
3076
|
+
//
|
|
3077
|
+
// 压图标题(名字 + 摘要)**保留** —— 插画是抽象主题图,没有名字
|
|
3078
|
+
// 认不出是哪张卡。
|
|
3079
|
+
)
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
// ── 叠层顺序:完整循环 ──
|
|
3083
|
+
//
|
|
3084
|
+
// 中心永远是 selectedPersona;左露边 = 它的**前一个**(列表头则接尾),
|
|
3085
|
+
// 右露边 = 它的**后一个**(列表尾则接头)。首尾相接 ⇒ 点右侧卡它滑到中心、
|
|
3086
|
+
// 原中心滑到左侧;点左侧卡反之 —— 整个列表是一个环。
|
|
3087
|
+
//
|
|
3088
|
+
// ⚠️ 这里**不能**用「selectedPersona 排头 + 其余顺排」那种线性序:
|
|
3089
|
+
// 那样点侧边卡时,露出的仍是同一批邻居,视觉上像「只有两张在动」;
|
|
3090
|
+
// 而真正该变的是「谁在中心、谁在旁边」。
|
|
3091
|
+
// ── 叠层:中心 + 两侧露边,首尾相接 ──
|
|
3092
|
+
//
|
|
3093
|
+
// 结构直接用「槽位」表达,不再用「stackOrder 下标 + 偏移」两层映射 ——
|
|
3094
|
+
// 那两层映射正是本轮三个 bug 的来源:下标与偏移各错一次就渲染出别的卡。
|
|
3095
|
+
//
|
|
3096
|
+
// slots 的每一项是 { name, offset }:
|
|
3097
|
+
// offset 0 = 中心(= selectedPersona)
|
|
3098
|
+
// offset -1 = 左侧露边(= 它的前一个,列表头则接尾)
|
|
3099
|
+
// offset +1 = 右侧露边(= 它的后一个,列表尾则接头)
|
|
3100
|
+
//
|
|
3101
|
+
// 预设数不足 3 时**不重复渲染同一张**:[A, B, A] 会产生重复 React key,
|
|
3102
|
+
// 且让"恰一张中心卡"的判据失真。
|
|
3103
|
+
const personaIndexOf = (name) => personaNames.indexOf(name)
|
|
3104
|
+
const stackSlots = (() => {
|
|
3105
|
+
const n = personaNames.length
|
|
3106
|
+
if (n === 0) return []
|
|
3107
|
+
const at = personaIndexOf(selectedPersona)
|
|
3108
|
+
if (at === -1) {
|
|
3109
|
+
// 选中项不在列表里(预设刚被删):退回线性序,中心取第一个
|
|
3110
|
+
return personaNames.map((name, i) => ({ name, offset: i === 0 ? 0 : 1 }))
|
|
3111
|
+
}
|
|
3112
|
+
if (n === 1) return [{ name: personaNames[0], offset: 0 }]
|
|
3113
|
+
if (n === 2) {
|
|
3114
|
+
const other = personaNames[1 - at]
|
|
3115
|
+
return [
|
|
3116
|
+
{ name: other, offset: -1 },
|
|
3117
|
+
{ name: personaNames[at], offset: 0 }
|
|
3118
|
+
]
|
|
3119
|
+
}
|
|
3120
|
+
return [
|
|
3121
|
+
{ name: personaNames[(at - 1 + n) % n], offset: -1 },
|
|
3122
|
+
{ name: personaNames[at], offset: 0 },
|
|
3123
|
+
{ name: personaNames[(at + 1) % n], offset: 1 }
|
|
3124
|
+
]
|
|
3125
|
+
})()
|
|
3126
|
+
|
|
3127
|
+
// 右栏详情:与设置页卡背**逐字相同**的六个维度 + 自定义指令全文,
|
|
3128
|
+
// 外加这一栏独有的操作按钮(用这个 / 取消本会话的临时人设)。
|
|
3129
|
+
const detailOf = (name) => {
|
|
3130
|
+
if (name === null) {
|
|
3131
|
+
return e('div', { className: 'soul-session-detail-empty' }, t('session.pickHint'))
|
|
3132
|
+
}
|
|
3133
|
+
const entry = state.personas ? state.personas[name] : null
|
|
3134
|
+
const builtin = !!(entry && entry.builtin)
|
|
3135
|
+
const isEffective = hasOverride && activePersona === name
|
|
3136
|
+
return e(Fragment, null,
|
|
3137
|
+
e('div', { className: 'soul-session-detail-head' },
|
|
3138
|
+
e('span', { className: 'soul-session-detail-name' }, name),
|
|
3139
|
+
builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin')),
|
|
3140
|
+
isEffective && e('span', { className: 'soul-session-badge' }, t('session.chipInUse'))
|
|
3141
|
+
),
|
|
3142
|
+
e('div', { className: 'soul-persona-dims' },
|
|
3143
|
+
...PERSONA_DIM_KEYS.map((dim) => e('div', { className: 'soul-persona-dim', key: dim },
|
|
3144
|
+
e('span', null, t(`field.${dim}`)),
|
|
3145
|
+
e('b', null, personaDimLabelOf(entry, dim, t))
|
|
3146
|
+
))
|
|
3147
|
+
),
|
|
3148
|
+
e('div', { className: 'soul-persona-back-label' }, t('personas.customInstructions')),
|
|
3149
|
+
e('div', { className: 'soul-persona-fulltext' }, personaFullTextOf(entry, t)),
|
|
3150
|
+
e('div', { className: 'soul-session-detail-actions' },
|
|
3151
|
+
e('button', {
|
|
3152
|
+
type: 'button',
|
|
3153
|
+
className: isEffective
|
|
3154
|
+
? 'soul-persona-action soul-persona-active'
|
|
3155
|
+
: 'soul-persona-action',
|
|
3156
|
+
disabled: busy,
|
|
3157
|
+
onClick: () => { void apply(name) }
|
|
3158
|
+
}, isEffective ? t('session.chipInUse') : t('session.useCard')),
|
|
3159
|
+
hasOverride && isEffective && e('button', {
|
|
3160
|
+
type: 'button',
|
|
3161
|
+
className: 'soul-session-chip soul-session-chip-clear',
|
|
3162
|
+
disabled: busy,
|
|
3163
|
+
onClick: () => { void clear() }
|
|
3164
|
+
}, t('session.clear'))
|
|
3165
|
+
),
|
|
3166
|
+
// 作用范围说明:紧跟操作按钮 —— 它解释的正是上面那个按钮改的东西
|
|
3167
|
+
// (「用这个」/「取消本会话的临时人设」都只作用于本会话)。
|
|
3168
|
+
// 放在分栏最底部离使用场景太远,用户看不到。
|
|
3169
|
+
hasOverride && e('div', { className: 'soul-session-scope-hint' },
|
|
3170
|
+
t('session.overrideHint'))
|
|
3171
|
+
)
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
return e('div', { className: 'soul-session-persona' },
|
|
3175
|
+
e('div', { className: 'soul-session-head' },
|
|
3176
|
+
e('h4', { className: 'soul-session-title' }, t('session.title')),
|
|
3177
|
+
hasOverride && e('span', { className: 'soul-session-badge' }, t('session.badge'))
|
|
3178
|
+
),
|
|
3179
|
+
|
|
3180
|
+
// 当前生效:先回答「现在是谁」,再给操作
|
|
3181
|
+
e('div', { className: 'soul-session-current' },
|
|
3182
|
+
e('div', { className: 'soul-session-current-name' },
|
|
3183
|
+
activePersona,
|
|
3184
|
+
// 特质突出点(0.9.0-ab):非默认的维度摘要,跟在名称后的小字。
|
|
3185
|
+
// personaRowMetaOf 的输出正是"风格 + 偏离默认值的维度" —— 与卡面
|
|
3186
|
+
// 摘要同源。无匹配预设(自定义配置)时不显示。
|
|
3187
|
+
activePersona && state.personas && state.personas[activePersona] && e('span', { className: 'soul-session-current-traits' },
|
|
3188
|
+
` · ${personaRowMetaOf(state.personas[activePersona], t)}`)
|
|
3189
|
+
),
|
|
3190
|
+
e('div', { className: 'soul-session-current-source' }, sourceText)
|
|
3191
|
+
),
|
|
3192
|
+
|
|
3193
|
+
(notice || error) && (error
|
|
3194
|
+
? e('p', { className: 'soul-error-notice', role: 'alert' }, t('session.applyFailed', { reason: error }))
|
|
3195
|
+
: e('p', { className: 'soul-saved-notice', role: 'status', 'aria-live': 'polite' }, notice)),
|
|
3196
|
+
|
|
3197
|
+
// 临时覆盖:左侧叠层轮转(中心大卡 + 两侧露边)+ 右侧详情面板
|
|
3198
|
+
e('div', { className: 'soul-field soul-persona-field' },
|
|
3199
|
+
// 标题与上方 tab 标题(.soul-session-title,14px/500)统一 ——
|
|
3200
|
+
// 两者是同层级区块标题,原先用 .soul-field-label(12px)视觉不齐(用户反馈)。
|
|
3201
|
+
e('h4', { className: 'soul-session-title' }, t('session.overrideTitle')),
|
|
3202
|
+
personaNames.length === 0
|
|
3203
|
+
? e('div', { className: 'soul-status' }, t('session.loading'))
|
|
3204
|
+
: e('div', { className: 'soul-session-split' },
|
|
3205
|
+
// 左侧:轮播区 = 叠层卡 + 下方导航
|
|
3206
|
+
e('div', { className: 'soul-session-carousel' },
|
|
3207
|
+
// 叠层:中心卡 = selectedPersona(offset 0),左/右各露出前一个/后一个。
|
|
3208
|
+
e('div', { className: 'soul-session-grid' },
|
|
3209
|
+
...stackSlots.map((slot) => cardFor(slot.name, slot.offset))
|
|
3210
|
+
),
|
|
3211
|
+
// 轮播导航:左右按钮 + 圆点(与设置页「人设预设」同一套)
|
|
3212
|
+
//
|
|
3213
|
+
// 为什么**循环**而不是到边界就 disabled:设置页是线性浏览(到头就停),
|
|
3214
|
+
// 这里刻意做成环形 —— 预设只有几张,用户想看「下一张」时不该先翻回第一张。
|
|
3215
|
+
e('div', { className: 'soul-persona-nav' },
|
|
3216
|
+
e('button', {
|
|
3217
|
+
type: 'button',
|
|
3218
|
+
className: 'soul-persona-navbtn',
|
|
3219
|
+
'data-dir': 'prev',
|
|
3220
|
+
'aria-label': t('personas.prev'),
|
|
3221
|
+
onClick: () => setSelectedPersona(
|
|
3222
|
+
personaNames[(personaIndexOf(selectedPersona) - 1 + personaNames.length) % personaNames.length]
|
|
3223
|
+
)
|
|
3224
|
+
}, '‹'),
|
|
3225
|
+
e('div', { className: 'soul-persona-dots', role: 'tablist' },
|
|
3226
|
+
...personaNames.map((n, i) => e('button', {
|
|
3227
|
+
key: n,
|
|
3228
|
+
type: 'button',
|
|
3229
|
+
role: 'tab',
|
|
3230
|
+
className: 'soul-persona-dot',
|
|
3231
|
+
'data-active': i === personaIndexOf(selectedPersona) ? 'true' : 'false',
|
|
3232
|
+
'aria-selected': i === personaIndexOf(selectedPersona) ? 'true' : 'false',
|
|
3233
|
+
'aria-label': `${n}(${i + 1}/${personaNames.length})`,
|
|
3234
|
+
onClick: () => setSelectedPersona(n)
|
|
3235
|
+
}))
|
|
3236
|
+
),
|
|
3237
|
+
e('button', {
|
|
3238
|
+
type: 'button',
|
|
3239
|
+
className: 'soul-persona-navbtn',
|
|
3240
|
+
'data-dir': 'next',
|
|
3241
|
+
'aria-label': t('personas.next'),
|
|
3242
|
+
onClick: () => setSelectedPersona(
|
|
3243
|
+
personaNames[(personaIndexOf(selectedPersona) + 1) % personaNames.length]
|
|
3244
|
+
)
|
|
3245
|
+
}, '›')
|
|
3246
|
+
)
|
|
3247
|
+
),
|
|
3248
|
+
e('div', { className: 'soul-session-detail' }, detailOf(selectedPersona))
|
|
3249
|
+
),
|
|
3250
|
+
),
|
|
3251
|
+
|
|
3252
|
+
// 覆盖里的自定义字段(不是预设带来的):让用户知道"现在生效的不是某个预设"
|
|
3253
|
+
hasOverride && (() => {
|
|
3254
|
+
const keys = Object.keys(view.override || {})
|
|
3255
|
+
if (keys.length === 0) return null
|
|
3256
|
+
return e('div', { className: 'soul-field' },
|
|
3257
|
+
e('div', { className: 'soul-field-label' }, t('session.customOverrides')),
|
|
3258
|
+
// 用字段**标签**(中英双语)而不是裸键名:HISTORY_FIELD_LABELS 是变更记录
|
|
3259
|
+
// 同款表,保证「改字段叫什么」在 tab、变更记录、设置页三处一致。
|
|
3260
|
+
// 缺失的键回退到原样(万一有人改了字段名又漏了标签)。
|
|
3261
|
+
e('div', { className: 'soul-session-fields' }, ...keys.map((key) => e('code', {
|
|
3262
|
+
key,
|
|
3263
|
+
className: 'soul-session-field'
|
|
3264
|
+
}, t(HISTORY_FIELD_LABELS[key] || key))))
|
|
3265
|
+
)
|
|
3266
|
+
})()
|
|
3267
|
+
)
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// 会话覆盖可提交的字段白名单(与服务端 lib/session-scope.mjs 的
|
|
3271
|
+
// SESSION_OVERRIDE_FIELDS 逐项一致)。客户端这份只用于「从预设里挑字段」,
|
|
3272
|
+
// 真正的校验在服务端 —— 客户端过滤只是少发几个必然被丢弃的键。
|
|
3273
|
+
const SESSION_OVERRIDE_KEYS = ['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
|
|
3274
|
+
|
|
3275
|
+
// -------------------------------------------------------------------------
|
|
3276
|
+
// 卡牌文案(模块级)—— 设置页、会话 tab、工作区卡片共用同一套措辞
|
|
3277
|
+
// -------------------------------------------------------------------------
|
|
3278
|
+
//
|
|
3279
|
+
// 为什么必须共用而不是各写一份:同一个预设在设置页显示「专业严谨 · 多列表」、
|
|
3280
|
+
// 在会话里显示「专业严谨」,用户会以为它们是两个东西。语义一致只能靠同一份实现,
|
|
3281
|
+
// 靠"记得同步"是不行的(这个项目已经因为手工同步的清单踩过两次)。
|
|
3282
|
+
//
|
|
3283
|
+
// 依赖注入的 `t`:渲染器按槽位 locale 注入翻译函数,模块级函数拿不到闭包里的 t。
|
|
3284
|
+
|
|
3285
|
+
// 预设摘要:风格 + 所有「偏离默认值」的维度。
|
|
3286
|
+
const personaRowMetaOf = (entry, t) => {
|
|
3287
|
+
const row = entry || {}
|
|
3288
|
+
const parts = []
|
|
3289
|
+
if (STYLE_VALUES.includes(row.style)) parts.push(t(`style.${row.style}`))
|
|
3290
|
+
else if (row.style) parts.push(row.style)
|
|
3291
|
+
for (const [field, allowed] of Object.entries(META_FIELD_VALUES)) {
|
|
3292
|
+
const value = row[field]
|
|
3293
|
+
if (!value || value === META_FIELD_DEFAULTS[field]) continue
|
|
3294
|
+
if (!allowed.includes(value)) continue
|
|
3295
|
+
parts.push(t(`meta.${field}.${value}`))
|
|
3296
|
+
}
|
|
3297
|
+
return parts.join(' · ')
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// 卡面效果栏:优先 customInstructions(人格差异的主要载体),没有则退回摘要。
|
|
3301
|
+
// 截断到第一句或 60 字:卡面空间有限,CSS 侧还有 3 行 line-clamp 兜底。
|
|
3302
|
+
const personaEffectTextOf = (entry, t) => {
|
|
3303
|
+
const raw = typeof (entry || {}).customInstructions === 'string' ? entry.customInstructions.trim() : ''
|
|
3304
|
+
if (raw === '') return personaRowMetaOf(entry, t)
|
|
3305
|
+
const firstSentence = raw.split(/[。;;\n]/)[0] || raw
|
|
3306
|
+
return firstSentence.length > 60 ? `${firstSentence.slice(0, 60)}…` : firstSentence
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// —— 卡背(详情面)的三样辅助,模块级 ——
|
|
3310
|
+
//
|
|
3311
|
+
// 列出「一个人设完整的六个维度」。顺序与设置页表单一致(风格 → 标题列表 →
|
|
3312
|
+
// 表情 → 表格 → 回复长度 → 语言),这样卡背读起来与设置页对得上。
|
|
3313
|
+
const PERSONA_DIM_KEYS = ['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language']
|
|
3314
|
+
|
|
3315
|
+
// 单个维度的「值」文案。刻意**不把与默认相同的项留空**:设置页里 trait 的默认值
|
|
3316
|
+
// 就是 'default',而卡背要回答的是「这个人设改了什么」—— 统一标成「默认」比留空
|
|
3317
|
+
// 更清楚(留空会让人以为渲染坏了)。
|
|
3318
|
+
// 语言维度复用 LANGUAGE_OPTIONS 的标签('中文' / 'English' 两种语言下同名,
|
|
3319
|
+
// 本来就是语言自称,无需翻译)。
|
|
3320
|
+
const personaDimLabelOf = (entry, dim, t) => {
|
|
3321
|
+
const row = entry || {}
|
|
3322
|
+
const value = row[dim]
|
|
3323
|
+
if (dim === 'language') {
|
|
3324
|
+
const opt = LANGUAGE_OPTIONS.find((o) => o.value === value)
|
|
3325
|
+
return opt ? opt.label : t('personas.dimDefault')
|
|
3326
|
+
}
|
|
3327
|
+
if (dim === 'style') {
|
|
3328
|
+
return STYLE_VALUES.includes(value) ? t(`style.${value}`) : t('personas.dimDefault')
|
|
3329
|
+
}
|
|
3330
|
+
// ⚠️ 判据是 TRAIT_VALUES.includes(value)(合法**取值** 'default'|'more'|'less'),
|
|
3331
|
+
// 不是检查 dim —— 这里曾经被误改成检查维度名,结果所有 trait 维度都落到「默认」。
|
|
3332
|
+
return TRAIT_VALUES.includes(value) ? t(`trait.${dim}.${value}`) : t('personas.dimDefault')
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
// 卡背的全文:这一次**不截断**(正面效果栏才截断)。
|
|
3336
|
+
// 预设没写自定义指令时给一句说明,而不是留空 —— 留空会让人以为渲染坏了。
|
|
3337
|
+
const personaFullTextOf = (entry, t) => {
|
|
3338
|
+
const raw = typeof (entry || {}).customInstructions === 'string'
|
|
3339
|
+
? entry.customInstructions.trim()
|
|
3340
|
+
: ''
|
|
3341
|
+
return raw === '' ? t('personas.noCustomInstructions') : raw
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
// -------------------------------------------------------------------------
|
|
3345
|
+
// 工作区人设(0.9.0-e)—— 注册到宿主的 `plugins.detail.section` 槽位
|
|
3346
|
+
// -------------------------------------------------------------------------
|
|
3347
|
+
//
|
|
3348
|
+
// 为什么在插件详情页而不是设置页:设置页是**快捷调整**(风格/语言/开关),
|
|
3349
|
+
// 而这里是「这个项目用什么人设」的结构性配置 —— 低频、需要空间(平铺卡片)、
|
|
3350
|
+
// 而且与变更记录放在同一页更符合"这是我的插件怎么配"的心智模型。
|
|
3351
|
+
//
|
|
3352
|
+
// 为什么平铺而不是下拉:工作区通常 3–10 个,每个有 title 与"是否已绑定"两个
|
|
3353
|
+
// 信息维度;下拉关闭时只能显示一项,用户必须展开才知道有哪些、哪些还没绑。
|
|
3354
|
+
// **未绑定的排前面** —— 那正是用户来这里要处理的事。
|
|
3355
|
+
function SoulWorkspaces(props) {
|
|
3356
|
+
const { controller, useSoulController } = props
|
|
3357
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
3358
|
+
const subject = props.subject || null
|
|
3359
|
+
// 只服务自己的详情页(与变更记录同一判据)。
|
|
3360
|
+
if (!subject || subject.kind !== 'bundle' || !subject.pkg || subject.pkg.name !== 'dsh-soul') return null
|
|
3361
|
+
|
|
3362
|
+
const state = useSoulController((state) => state)
|
|
3363
|
+
const [view, setView] = React.useState(null)
|
|
3364
|
+
const [error, setError] = React.useState(null)
|
|
3365
|
+
const [notice, setNotice] = React.useState(null)
|
|
3366
|
+
const [busy, setBusy] = React.useState(false)
|
|
3367
|
+
// 正在展开人设选择的工作区(null = 没有)
|
|
3368
|
+
const [picking, setPicking] = React.useState(null)
|
|
3369
|
+
// 待确认解绑的工作区(内联二次确认,与删除预设同一套)
|
|
3370
|
+
const [pendingUnbind, setPendingUnbind] = React.useState(null)
|
|
3371
|
+
|
|
3372
|
+
const reload = async () => {
|
|
3373
|
+
try {
|
|
3374
|
+
const payload = await controller.fetchWorkspaces()
|
|
3375
|
+
setView(payload)
|
|
3376
|
+
setError(null)
|
|
3377
|
+
} catch (err) {
|
|
3378
|
+
// ⚠️ 失败时必须清空 view:否则界面继续渲染**上一次成功**的列表,
|
|
3379
|
+
// 用户看到的是过期数据却以为是当前的(错误提示与旧卡片同屏,更混乱)。
|
|
3380
|
+
setView(null)
|
|
3381
|
+
setError(messageOf(err))
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
React.useEffect(() => {
|
|
3386
|
+
void reload()
|
|
3387
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3388
|
+
}, [])
|
|
3389
|
+
|
|
3390
|
+
// 解绑的二次确认 5 秒后复位(同设置页的 armed 态)
|
|
3391
|
+
React.useEffect(() => {
|
|
3392
|
+
if (pendingUnbind === null) return undefined
|
|
3393
|
+
const timer = setTimeout(() => setPendingUnbind(null), 5000)
|
|
3394
|
+
return () => clearTimeout(timer)
|
|
3395
|
+
}, [pendingUnbind])
|
|
3396
|
+
|
|
3397
|
+
const personaNames = state.personas
|
|
3398
|
+
? Object.keys(state.personas).sort((a, b) => {
|
|
3399
|
+
const aBuiltin = state.personas[a] && state.personas[a].builtin ? 0 : 1
|
|
3400
|
+
const bBuiltin = state.personas[b] && state.personas[b].builtin ? 0 : 1
|
|
3401
|
+
return aBuiltin - bBuiltin
|
|
3402
|
+
})
|
|
3403
|
+
: []
|
|
3404
|
+
|
|
3405
|
+
const bind = async (workspace, persona) => {
|
|
3406
|
+
if (busy) return
|
|
3407
|
+
setBusy(true)
|
|
3408
|
+
setError(null)
|
|
3409
|
+
try {
|
|
3410
|
+
const payload = await controller.saveWorkspacePersona(workspace.workspaceId, persona)
|
|
3411
|
+
setView(payload)
|
|
3412
|
+
setPicking(null)
|
|
3413
|
+
setNotice(t('workspaces.applied', { name: persona, title: workspace.title || workspace.path }))
|
|
3414
|
+
} catch (err) {
|
|
3415
|
+
setError(messageOf(err))
|
|
3416
|
+
} finally {
|
|
3417
|
+
setBusy(false)
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
const unbind = async (workspace) => {
|
|
3422
|
+
if (pendingUnbind !== workspace.workspaceId) {
|
|
3423
|
+
setPendingUnbind(workspace.workspaceId)
|
|
3424
|
+
return
|
|
3425
|
+
}
|
|
3426
|
+
setPendingUnbind(null)
|
|
3427
|
+
if (busy) return
|
|
3428
|
+
setBusy(true)
|
|
3429
|
+
setError(null)
|
|
3430
|
+
try {
|
|
3431
|
+
const payload = await controller.removeWorkspacePersona(workspace.workspaceId)
|
|
3432
|
+
setView(payload)
|
|
3433
|
+
setNotice(t('workspaces.unbindDone', { title: workspace.title || workspace.path }))
|
|
3434
|
+
} catch (err) {
|
|
3435
|
+
setError(messageOf(err))
|
|
3436
|
+
} finally {
|
|
3437
|
+
setBusy(false)
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// 加载中 / 失败 / 空 三态必须分开(0.8.0 的 C14 就是被"加载中显示成空"抓到的)
|
|
3442
|
+
const body = (() => {
|
|
3443
|
+
if (error !== null && view === null) {
|
|
3444
|
+
return e('div', { className: 'soul-error' }, t('workspaces.loadFailed', { reason: error }))
|
|
3445
|
+
}
|
|
3446
|
+
if (view === null) {
|
|
3447
|
+
return e('div', { className: 'soul-status' }, t('workspaces.loading'))
|
|
3448
|
+
}
|
|
3449
|
+
if (view.indexProblem) {
|
|
3450
|
+
// 宿主服务不可用:如实说明,不给一个"看起来是空的"列表
|
|
3451
|
+
return e('div', { className: 'soul-error' }, t('workspaces.serverProblem', { reason: view.indexProblem }))
|
|
3452
|
+
}
|
|
3453
|
+
const items = Array.isArray(view.workspaces) ? view.workspaces : []
|
|
3454
|
+
if (items.length === 0) {
|
|
3455
|
+
return e('div', { className: 'soul-status' }, t('workspaces.empty'))
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// 未绑定的排前面:那正是用户来这一页要处理的事
|
|
3459
|
+
const sorted = items.slice().sort((a, b) => {
|
|
3460
|
+
if (a.bound !== b.bound) return a.bound ? 1 : -1
|
|
3461
|
+
return String(a.title || a.path).localeCompare(String(b.title || b.path))
|
|
3462
|
+
})
|
|
3463
|
+
|
|
3464
|
+
return e('div', { className: 'soul-ws-list' }, ...sorted.map((ws) => {
|
|
3465
|
+
const title = ws.title || ws.path
|
|
3466
|
+
const armed = pendingUnbind === ws.workspaceId
|
|
3467
|
+
const isPicking = picking === ws.workspaceId
|
|
3468
|
+
return e('div', {
|
|
3469
|
+
key: ws.workspaceId,
|
|
3470
|
+
className: ws.bound ? 'soul-ws-card soul-ws-card-bound' : 'soul-ws-card',
|
|
3471
|
+
'data-bound': ws.bound ? 'true' : 'false'
|
|
3472
|
+
},
|
|
3473
|
+
e('div', { className: 'soul-ws-main' },
|
|
3474
|
+
e('div', { className: 'soul-ws-title' }, title),
|
|
3475
|
+
e('code', { className: 'soul-ws-path', title: ws.path }, ws.path),
|
|
3476
|
+
ws.invalidPersona && e('div', { className: 'soul-ws-invalid' },
|
|
3477
|
+
e('span', null, t('workspaces.invalid', { name: ws.persona || '' })),
|
|
3478
|
+
e('span', { className: 'soul-status' }, t('workspaces.invalidHint')))
|
|
3479
|
+
),
|
|
3480
|
+
e('div', { className: 'soul-ws-actions' },
|
|
3481
|
+
// 已绑定:显示当前人设名 + 改绑 / 解绑
|
|
3482
|
+
ws.bound
|
|
3483
|
+
? e(Fragment, null,
|
|
3484
|
+
e('span', { className: 'soul-ws-persona' }, ws.persona || t('workspaces.bound')),
|
|
3485
|
+
// 0.9.0-ao:**文字按钮**,规格照抄宿主设置-模型页的「编辑 / 删除」。
|
|
3486
|
+
//
|
|
3487
|
+
// 上一版我误抄成了 .iconButton(图标按钮)—— 那是宿主页面里另一处
|
|
3488
|
+
// 的形态,不是这两个按钮。宿主真正的实现是:
|
|
3489
|
+
// 编辑 → className = secondaryButton(描边、文字、label-primary)
|
|
3490
|
+
// 删除 → className = dangerButton(无描边、红字、hover 浅红底)
|
|
3491
|
+
// 两者共用紧凑尺寸:radius-sm / height 28 / padding 0 10 / 12px / lh 18。
|
|
3492
|
+
e('button', {
|
|
3493
|
+
type: 'button',
|
|
3494
|
+
className: 'soul-ws-btn',
|
|
3495
|
+
disabled: busy,
|
|
3496
|
+
onClick: () => setPicking(isPicking ? null : ws.workspaceId)
|
|
3497
|
+
}, t('workspaces.rebind')),
|
|
3498
|
+
e('button', {
|
|
3499
|
+
type: 'button',
|
|
3500
|
+
className: armed ? 'soul-ws-btn soul-ws-btn-danger soul-ws-btn-armed' : 'soul-ws-btn soul-ws-btn-danger',
|
|
3501
|
+
'data-armed': armed ? 'true' : 'false',
|
|
3502
|
+
disabled: busy,
|
|
3503
|
+
onClick: () => { void unbind(ws) }
|
|
3504
|
+
}, t(armed ? 'workspaces.unbindArmed' : 'workspaces.unbind'))
|
|
3505
|
+
)
|
|
3506
|
+
: e(Fragment, null,
|
|
3507
|
+
e('span', { className: 'soul-ws-unbound' }, t('workspaces.unbound')),
|
|
3508
|
+
// 0.9.0-am:改用宿主 ui.Button —— 原先自绘的
|
|
3509
|
+
// .soul-ws-action-primary 是实心主色底(浅色主题≈黑),
|
|
3510
|
+
// 而共用的 .soul-ws-action:hover 又把文字染成 brand 近黑色,
|
|
3511
|
+
// 黑底黑字 ⇒ 整块全黑(用户两次反馈的真实成因)。
|
|
3512
|
+
e(ui.Button, {
|
|
3513
|
+
variant: 'outline',
|
|
3514
|
+
size: 'sm',
|
|
3515
|
+
disabled: busy || personaNames.length === 0,
|
|
3516
|
+
onClick: () => setPicking(isPicking ? null : ws.workspaceId)
|
|
3517
|
+
}, t('workspaces.bind'))
|
|
3518
|
+
)
|
|
3519
|
+
),
|
|
3520
|
+
// 内联展开人设胶囊(不另开弹层):一次点击完成绑定
|
|
3521
|
+
isPicking && e('div', { className: 'soul-ws-picker' },
|
|
3522
|
+
e('span', { className: 'soul-ws-picker-label' }, t('workspaces.pickPersona')),
|
|
3523
|
+
e('div', { className: 'soul-session-chips' },
|
|
3524
|
+
...personaNames.map((name) => e('button', {
|
|
3525
|
+
key: name,
|
|
3526
|
+
type: 'button',
|
|
3527
|
+
className: 'soul-session-chip',
|
|
3528
|
+
disabled: busy,
|
|
3529
|
+
onClick: () => { void bind(ws, name) }
|
|
3530
|
+
}, name))
|
|
3531
|
+
)
|
|
3532
|
+
)
|
|
3533
|
+
)
|
|
3534
|
+
}))
|
|
3535
|
+
})()
|
|
3536
|
+
|
|
3537
|
+
const boundCount = Array.isArray(view && view.workspaces)
|
|
3538
|
+
? view.workspaces.filter((w) => w.bound).length
|
|
3539
|
+
: 0
|
|
3540
|
+
const total = Array.isArray(view && view.workspaces) ? view.workspaces.length : 0
|
|
3541
|
+
|
|
3542
|
+
return e('section', { className: 'soul-detail-history', 'data-soul-workspaces': 'true' },
|
|
3543
|
+
e('div', { className: 'soul-detail-history-head' },
|
|
3544
|
+
e('h4', { className: 'soul-detail-history-title' }, t('workspaces.title')),
|
|
3545
|
+
e('span', { className: 'soul-detail-history-count' },
|
|
3546
|
+
t('workspaces.count', { bound: String(boundCount), total: String(total) }))
|
|
3547
|
+
),
|
|
3548
|
+
(notice || error) && (error
|
|
3549
|
+
? e('p', { className: 'soul-error-notice', role: 'alert' }, t('workspaces.applyFailed', { reason: error }))
|
|
3550
|
+
: e('p', { className: 'soul-saved-notice', role: 'status', 'aria-live': 'polite' }, notice)),
|
|
3551
|
+
e('div', { className: 'soul-note' }, t('workspaces.hint')),
|
|
3552
|
+
body
|
|
3553
|
+
)
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
// -------------------------------------------------------------------------
|
|
3557
|
+
// 会话覆盖清理(0.9.0-f)—— 插件详情页的第三个区块
|
|
3558
|
+
// -------------------------------------------------------------------------
|
|
3559
|
+
//
|
|
3560
|
+
// 为什么也要一个按钮(而不是只留命令):命令是给愿意敲的人的,而"我这儿攒了一堆
|
|
3561
|
+
// 不知道哪来的会话配置"是所有人都会遇到的事 —— 那时他们不会想到去查文档。
|
|
3562
|
+
//
|
|
3563
|
+
// 交互刻意做成两步:先**预览**(不删任何东西),确认后才清理。
|
|
3564
|
+
// 删除不可逆,且回收依据(会话清单)可能不可用,所以默认永远站在"不删"一侧。
|
|
3565
|
+
function SoulSessions(props) {
|
|
3566
|
+
const { controller } = props
|
|
3567
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
3568
|
+
const subject = props.subject || null
|
|
3569
|
+
if (!subject || subject.kind !== 'bundle' || !subject.pkg || subject.pkg.name !== 'dsh-soul') return null
|
|
3570
|
+
|
|
3571
|
+
const [plan, setPlan] = React.useState(null)
|
|
3572
|
+
const [error, setError] = React.useState(null)
|
|
3573
|
+
const [notice, setNotice] = React.useState(null)
|
|
3574
|
+
const [busy, setBusy] = React.useState(false)
|
|
3575
|
+
// 已预览、待确认(null = 还没预览过)
|
|
3576
|
+
const [armedCount, setArmedCount] = React.useState(null)
|
|
3577
|
+
|
|
3578
|
+
const scan = async () => {
|
|
3579
|
+
if (busy) return
|
|
3580
|
+
setBusy(true)
|
|
3581
|
+
setError(null)
|
|
3582
|
+
setNotice(null)
|
|
3583
|
+
try {
|
|
3584
|
+
const payload = await controller.scanSessionScopeGc()
|
|
3585
|
+
setPlan(payload)
|
|
3586
|
+
// 预览一次即 arm:按钮文案随之变成「确认清理 N 项?」
|
|
3587
|
+
setArmedCount(Array.isArray(payload.collectable) ? payload.collectable.length : 0)
|
|
3588
|
+
} catch (err) {
|
|
3589
|
+
setPlan(null)
|
|
3590
|
+
setError(messageOf(err))
|
|
3591
|
+
} finally {
|
|
3592
|
+
setBusy(false)
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
const apply = async () => {
|
|
3597
|
+
if (busy || plan === null) return
|
|
3598
|
+
// 第二次点击才真删;第一次只 arm(按钮文案已经说明了后果)
|
|
3599
|
+
if (armedCount === null) { void scan(); return }
|
|
3600
|
+
if (armedCount === 0) { void scan(); return }
|
|
3601
|
+
setBusy(true)
|
|
3602
|
+
setError(null)
|
|
3603
|
+
try {
|
|
3604
|
+
const payload = await controller.applySessionScopeGc()
|
|
3605
|
+
setPlan(payload)
|
|
3606
|
+
setArmedCount(null)
|
|
3607
|
+
const removed = Array.isArray(payload.removed) ? payload.removed.length : 0
|
|
3608
|
+
setNotice(t('sessions.collected', { count: String(removed) }))
|
|
3609
|
+
} catch (err) {
|
|
3610
|
+
setError(messageOf(err))
|
|
3611
|
+
} finally {
|
|
3612
|
+
setBusy(false)
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
const collectable = plan && Array.isArray(plan.collectable) ? plan.collectable.length : 0
|
|
3617
|
+
const body = (() => {
|
|
3618
|
+
if (error !== null && plan === null) {
|
|
3619
|
+
return e('div', { className: 'soul-error' }, t('sessions.scanFailed', { reason: error }))
|
|
3620
|
+
}
|
|
3621
|
+
if (plan === null) {
|
|
3622
|
+
// 还没点「预览」:不自动扫描。清理是运维动作,不该在打开详情页时悄悄跑一遍。
|
|
3623
|
+
return e('div', { className: 'soul-status' }, t('sessions.previewHint'))
|
|
3624
|
+
}
|
|
3625
|
+
return e(Fragment, null,
|
|
3626
|
+
// 扫描不完整 → 明确说"没做判定",并**隐藏清理按钮**(没有判定就没有可清理项)
|
|
3627
|
+
plan.scanComplete === false
|
|
3628
|
+
? e('div', { className: 'soul-status' }, t('sessions.scanIncomplete'))
|
|
3629
|
+
: e('div', { className: 'soul-status' },
|
|
3630
|
+
t('sessions.summary', {
|
|
3631
|
+
total: String(plan.total || 0),
|
|
3632
|
+
markable: String((plan.markable || []).length),
|
|
3633
|
+
collectable: String(collectable),
|
|
3634
|
+
quarantine: String((plan.quarantine || []).length)
|
|
3635
|
+
})),
|
|
3636
|
+
// 可回收清单:让用户在删之前看见具体是哪些
|
|
3637
|
+
collectable > 0 && e('div', { className: 'soul-field' },
|
|
3638
|
+
e('div', { className: 'soul-field-label' }, t('sessions.collect')),
|
|
3639
|
+
...plan.collectable.slice(0, 20).map((item) => e('code', {
|
|
3640
|
+
key: item.sessionId || item.file,
|
|
3641
|
+
className: 'soul-session-field'
|
|
3642
|
+
}, String(item.sessionId || item.file)))
|
|
3643
|
+
),
|
|
3644
|
+
plan.scanComplete !== false && collectable === 0 && e('div', { className: 'soul-status' }, t('sessions.nothing'))
|
|
3645
|
+
)
|
|
3646
|
+
})()
|
|
3647
|
+
|
|
3648
|
+
return e('section', { className: 'soul-detail-history', 'data-soul-sessions': 'true' },
|
|
3649
|
+
e('div', { className: 'soul-detail-history-head' },
|
|
3650
|
+
e('h4', { className: 'soul-detail-history-title' }, t('sessions.title'))
|
|
3651
|
+
),
|
|
3652
|
+
(notice || error) && (error
|
|
3653
|
+
? e('p', { className: 'soul-error-notice', role: 'alert' }, t('sessions.scanFailed', { reason: error }))
|
|
3654
|
+
: e('p', { className: 'soul-saved-notice', role: 'status', 'aria-live': 'polite' }, notice)),
|
|
3655
|
+
e('div', { className: 'soul-note' }, t('sessions.hint')),
|
|
3656
|
+
body,
|
|
3657
|
+
// 用独立 class 而不是 .soul-buttons:那个 class 属于**设置页的保存按钮区**,
|
|
3658
|
+
// 复用会让「保存设置在哪里」这类断言抓到错误的块(verify-config 立刻抓到过)。
|
|
3659
|
+
e('div', { className: 'soul-sessions-actions' },
|
|
3660
|
+
e('button', {
|
|
3661
|
+
type: 'button',
|
|
3662
|
+
className: 'soul-ws-action',
|
|
3663
|
+
disabled: busy,
|
|
3664
|
+
onClick: () => { void scan() }
|
|
3665
|
+
}, busy ? t('sessions.scanning') : t('sessions.collect')),
|
|
3666
|
+
// 真删按钮的三个条件缺一不可:
|
|
3667
|
+
// ① 预览过(armedCount !== null);
|
|
3668
|
+
// ② 有可回收项;
|
|
3669
|
+
// ③ **本轮扫描完整** —— 扫描不完整时状态机对一切返回 keep,那时即便
|
|
3670
|
+
// 列表里出现 collectable(服务端逻辑变更 / 数据异常),也不该给删除入口。
|
|
3671
|
+
// 这是"缺证据时保留"在 UI 上的最后一道闸。
|
|
3672
|
+
armedCount !== null && collectable > 0 && plan !== null && plan.scanComplete !== false && e('button', {
|
|
3673
|
+
type: 'button',
|
|
3674
|
+
className: 'soul-ws-action soul-ws-action-armed',
|
|
3675
|
+
disabled: busy,
|
|
3676
|
+
onClick: () => { void apply() }
|
|
3677
|
+
}, t('sessions.collectArmed', { count: String(collectable) }))
|
|
3678
|
+
)
|
|
3679
|
+
)
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
// -------------------------------------------------------------------------
|
|
3683
|
+
// 会话人设标签(只读)—— 挂在会话 header
|
|
3684
|
+
// -------------------------------------------------------------------------
|
|
3685
|
+
//
|
|
3686
|
+
// 为什么从输入框左侧挪到 header:
|
|
3687
|
+
// · 输入框左侧横向空间极窄,「个性化已启用 · 人设名 · 层级」根本放不下
|
|
3688
|
+
// (实测被 overflow:hidden 裁掉);
|
|
3689
|
+
// · 会话 header 那一行(标题旁)本来就有横向空间,宿主的「创造模式」标签就挂在那;
|
|
3690
|
+
// · 宿主自己在窄容器时会把 header 标签**隐藏**(AgentPresetLabel 的
|
|
3691
|
+
// @container (width<=540px){ display:none })—— 说明这个位置的设计就是
|
|
3692
|
+
// 「有空间才显示」,与我们的诉求一致。
|
|
3693
|
+
//
|
|
3694
|
+
// 只读:会话的 composition 一旦开始就固定了,header 里放可切换控件会承诺
|
|
3695
|
+
// 宿主拒绝兑现的事(这是宿主 AgentPresetLabel 的原话,理由同样成立)。
|
|
3696
|
+
// 要改人设去「人设」tab 或设置页。
|
|
3697
|
+
// -------------------------------------------------------------------------
|
|
3698
|
+
// 面具图标几何(唯一来源)
|
|
3699
|
+
// -------------------------------------------------------------------------
|
|
3700
|
+
//
|
|
3701
|
+
// 这个面具在三个地方出现:会话 header 标签、侧栏会话行徽标、侧栏面板图标。
|
|
3702
|
+
// 之前各处各抄一份 path,结果「下巴改圆」只改了 header,另两处仍用尖下巴 ——
|
|
3703
|
+
// 同一副面具在界面上长出两种轮廓(用户实测发现)。现在只留这一份。
|
|
3704
|
+
//
|
|
3705
|
+
// 造型:戏剧双面 —— 左眼笑(上弯)、右眼哭(下弯)、嘴左高右低;盾形圆下巴。
|
|
3706
|
+
const SOUL_MASK_PATHS = [
|
|
3707
|
+
'M2.2 3.0 C4.2 1.8 11.8 1.8 13.8 3.0 C14.4 6.4 13.0 11.4 11.4 12.4 C10.2 13.1 9.2 13.4 8.0 13.4 C6.8 13.4 5.8 13.1 4.6 12.4 C3.0 11.4 1.6 6.4 2.2 3.0 Z',
|
|
3708
|
+
'M4.0 6.0 C4.7 5.2 6.1 5.2 6.8 6.0',
|
|
3709
|
+
'M9.2 5.6 C9.9 6.4 11.3 6.4 12.0 5.6',
|
|
3710
|
+
'M4.6 9.6 C6.0 10.2 9.0 9.6 11.2 8.6'
|
|
3711
|
+
]
|
|
3712
|
+
|
|
3713
|
+
/** 渲染面具 SVG。props 里的宽高/类名/内联样式原样透传,描边规格在此统一。 */
|
|
3714
|
+
const soulMask = (props) => e('svg', {
|
|
3715
|
+
viewBox: '0 0 16 16',
|
|
3716
|
+
fill: 'none',
|
|
3717
|
+
stroke: 'currentColor',
|
|
3718
|
+
strokeWidth: 1.4,
|
|
3719
|
+
strokeLinecap: 'round',
|
|
3720
|
+
strokeLinejoin: 'round',
|
|
3721
|
+
'aria-hidden': true,
|
|
3722
|
+
...(props || {})
|
|
3723
|
+
}, ...SOUL_MASK_PATHS.map((d, i) => e('path', { key: i, d })))
|
|
3724
|
+
|
|
3725
|
+
function SoulSessionPersonaLabel(props) {
|
|
3726
|
+
const { useSoulController, controller } = props
|
|
3727
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
3728
|
+
const state = useSoulController((state) => state)
|
|
3729
|
+
const enabled = state.enabled === true
|
|
3730
|
+
// scope:'session' 槽位的 inject 收到的是**位置参数** [sessionId](见宿主契约
|
|
3731
|
+
// FactoryInjectParams:scope 'session' → [sessionId: SessionId])。
|
|
3732
|
+
const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : null
|
|
3733
|
+
// 数据来自**全局 store**(sessionScopes[sessionId]),不是组件自己取。
|
|
3734
|
+
//
|
|
3735
|
+
// 为什么改:原实现自己在 useEffect 里 fetch,依赖只有 [sessionId] ——
|
|
3736
|
+
// 会话里切换人设时 sessionId 没变 ⇒ effect 不重跑 ⇒ header 标签仍显示旧人设
|
|
3737
|
+
// (用户报的 bug:人设 tab 里切换了,标题栏不变)。
|
|
3738
|
+
//
|
|
3739
|
+
// 现在 store 是单一数据源:saveSessionScope / clearSessionScope 成功后把服务端
|
|
3740
|
+
// 返回的解析结果写进 store ⇒ 所有看它的地方一起刷新,不必各自轮询。
|
|
3741
|
+
const scopeView = (state.sessionScopes && state.sessionScopes[String(sessionId)]) || null
|
|
3742
|
+
// 请求是否失败过:失败时不得静默退回全局值(那会重现"界面说 A、模型用 B"的割裂)
|
|
3743
|
+
const [scopeFailed, setScopeFailed] = React.useState(false)
|
|
3744
|
+
|
|
3745
|
+
// 兜底:store 里还没有这个会话的记录时(首次打开、别的组件写入的),
|
|
3746
|
+
// 去取一次。取到后写回 store,后续都走 store。
|
|
3747
|
+
React.useEffect(() => {
|
|
3748
|
+
if (sessionId === null) return undefined
|
|
3749
|
+
if (scopeView !== null) return undefined
|
|
3750
|
+
let cancelled = false
|
|
3751
|
+
void controller.fetchCurrentSessionScope(sessionId).then((payload) => {
|
|
3752
|
+
if (cancelled) return
|
|
3753
|
+
controller.setSessionScope(sessionId, {
|
|
3754
|
+
activePersona: typeof payload.activePersona === 'string' ? payload.activePersona : null,
|
|
3755
|
+
recordedPersona: typeof payload.recordedPersona === 'string' ? payload.recordedPersona : null,
|
|
3756
|
+
globalPersona: typeof payload.globalPersona === 'string' ? payload.globalPersona : null,
|
|
3757
|
+
source: typeof payload.source === 'string' ? payload.source : null,
|
|
3758
|
+
workspaceId: payload.workspaceId || null,
|
|
3759
|
+
workspaceTitle: typeof payload.workspaceTitle === 'string' ? payload.workspaceTitle : null,
|
|
3760
|
+
override: payload.override || null
|
|
3761
|
+
})
|
|
3762
|
+
setScopeFailed(false)
|
|
3763
|
+
}).catch(() => {
|
|
3764
|
+
if (cancelled) return
|
|
3765
|
+
setScopeFailed(true)
|
|
3766
|
+
})
|
|
3767
|
+
return () => { cancelled = true }
|
|
3768
|
+
}, [sessionId, scopeView])
|
|
3769
|
+
|
|
3770
|
+
// 关闭个性化时不显示(人设此刻不生效,挂名字会误导)
|
|
3771
|
+
if (!enabled) return null
|
|
3772
|
+
|
|
3773
|
+
const activePersona = state.activePersona
|
|
3774
|
+
const scopeActive = scopeView && typeof scopeView.activePersona === 'string' && scopeView.activePersona !== ''
|
|
3775
|
+
? scopeView.activePersona
|
|
3776
|
+
: null
|
|
3777
|
+
const displayPersona = scopeActive
|
|
3778
|
+
|| (scopeFailed ? t('quick.scopeUnknown') : (activePersona || t('quick.custom')))
|
|
3779
|
+
const scopeTag = (() => {
|
|
3780
|
+
if (!scopeView) return null
|
|
3781
|
+
if (scopeView.source === 'session') return t('quick.scopeSession')
|
|
3782
|
+
if (scopeView.source === 'workspace') {
|
|
3783
|
+
return t('quick.scopeWorkspace', { name: scopeView.workspaceTitle || scopeView.workspaceId || '' })
|
|
3784
|
+
}
|
|
3785
|
+
if (scopeView.source === 'directory') return t('quick.scopeDirectory')
|
|
3786
|
+
if (scopeView.source === 'global') return t('quick.scopeGlobal')
|
|
3787
|
+
return t('quick.scopeUnknown')
|
|
3788
|
+
})()
|
|
3789
|
+
|
|
3790
|
+
return e('span', { className: 'soul-session-persona-label' },
|
|
3791
|
+
// 图标:宿主的 IconAgentPresetOutlineRegular(智能体预设)。
|
|
3792
|
+
//
|
|
3793
|
+
// 为什么用宿主现成图标而不是自绘:
|
|
3794
|
+
// · 语义正好 —— 「Agent 预设」就是「人设」;
|
|
3795
|
+
// · 与**同一位置**的邻居(header 上的「创造模式」标签)用同一套图标语言,
|
|
3796
|
+
// 视觉统一,用户的识别成本最低;
|
|
3797
|
+
// · 随主题变色(currentColor),不用管深浅色;
|
|
3798
|
+
// · 免去自绘 SVG 的描边/尺寸对齐问题。
|
|
3799
|
+
//
|
|
3800
|
+
// 宿主客户端包(ui-agent-preset / ui-chat / ui-tool)都是
|
|
3801
|
+
// require('@deepseek-ai/dsh-client-ui-primitives') 拿它的,
|
|
3802
|
+
// 与本插件的 require 机制完全相同。
|
|
3803
|
+
//
|
|
3804
|
+
// 尺寸 14:与邻居 AgentPresetLabel 的图标一致(size: 14)。
|
|
3805
|
+
// 面具图标(0.9.0-w 自绘,0.9.0-x 定稿为「喜剧+悲剧」双面):戏剧面具 =
|
|
3806
|
+
// 「戴上面具扮演另一个角色」+ 一张脸半笑半哭,比 agent-preset 图标更准确
|
|
3807
|
+
// 地表达「人设」,也避免与「创造模式」标签撞图标。stroke 用 currentColor。
|
|
3808
|
+
// 渲染验证:Chrome headless 截 96/28/14px 三档,14px 下三笔都清晰。
|
|
3809
|
+
soulMask({
|
|
3810
|
+
className: 'soul-session-persona-label-icon',
|
|
3811
|
+
width: 14,
|
|
3812
|
+
height: 14,
|
|
3813
|
+
strokeWidth: 1.5
|
|
3814
|
+
}),
|
|
3815
|
+
e('span', { className: 'soul-session-persona-label-name' }, displayPersona),
|
|
3816
|
+
scopeTag && e('span', { className: 'soul-session-persona-label-scope' }, scopeTag)
|
|
3817
|
+
)
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
// -------------------------------------------------------------------------
|
|
3821
|
+
// 侧栏面板图标「Soul 人设」(0.9.0-ac)
|
|
3822
|
+
// -------------------------------------------------------------------------
|
|
3823
|
+
//
|
|
3824
|
+
// 槽位:sidebar.panellist —— kind:'list' / scope:'root',"Global panel icons"。
|
|
3825
|
+
// 宿主 occupants:插件管理器(plugins)、自动化任务(schedules)等。
|
|
3826
|
+
//
|
|
3827
|
+
// owner props(SidebarPanelIconOwnerProps):{ size, active } ——
|
|
3828
|
+
// 宿主把"要多大的方块 / 当前是否选中"传给图标,照做即可与邻居对齐。
|
|
3829
|
+
//
|
|
3830
|
+
// ⚠️ inject 与 session 槽位不同:panellist 是 scope:'root',runInject 只传
|
|
3831
|
+
// [binding.key, actions](root 无 binding ⇒ 无参)—— inject 只需注入无状态
|
|
3832
|
+
// 内容,宿主的 PanelRow 自己拥有点击(拿我们的 id 去 selectPanel)。
|
|
3833
|
+
function SoulPanelIcon(props) {
|
|
3834
|
+
// 宿主要求方块尺寸与选中态;未传时给默认(14px)
|
|
3835
|
+
const size = typeof props.size === 'number' ? props.size : 14
|
|
3836
|
+
return e('span', {
|
|
3837
|
+
className: 'soul-panel-icon',
|
|
3838
|
+
style: { width: size, height: size },
|
|
3839
|
+
'aria-hidden': true
|
|
3840
|
+
},
|
|
3841
|
+
// 面具(与 header 标签同一副「喜剧+悲剧」),stroke: currentColor
|
|
3842
|
+
soulMask({ width: '100%', height: '100%' })
|
|
3843
|
+
)
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
// -------------------------------------------------------------------------
|
|
3847
|
+
// 侧栏会话行 hover 面具按钮(0.9.0-ab)
|
|
3848
|
+
// -------------------------------------------------------------------------
|
|
3849
|
+
//
|
|
3850
|
+
// 槽位:sidebar.workspaces.session.row.action —— kind:'list' / scope:'root',
|
|
3851
|
+
// 宿主文档:"The hover buttons at the end of one Session row, in ascending
|
|
3852
|
+
// order, after the '...' menu trigger." ui-workspace 自己注册了
|
|
3853
|
+
// archive(100) / pin(200),我们 order:300 排在后面。
|
|
3854
|
+
//
|
|
3855
|
+
// owner props(宿主 SessionRowOwnerProps):{ sessionId, displayTitle } ——
|
|
3856
|
+
// 能知道这一行是哪个会话,配合 store.bindingsSessions(含每会话的
|
|
3857
|
+
// persona / binding)即可显示该会话当前生效的人设。
|
|
3858
|
+
//
|
|
3859
|
+
// 展示形态:面具图标按钮,hover 出 tooltip「人设:X(来源)」。
|
|
3860
|
+
// 只读 —— 点击不做任何事(按钮本身禁用),它只是把"这个会话现在是谁"
|
|
3861
|
+
// 显式摆到侧栏,不用先点开会话再看。
|
|
3862
|
+
// 侧栏徽标的数据去重句柄(模块级):侧栏一次会挂很多行,每行各自触发一次
|
|
3863
|
+
// 拉取会打出一串重复请求。同一时刻只允许一个在飞。
|
|
3864
|
+
let sidebarBindingsRequest = null
|
|
3865
|
+
|
|
3866
|
+
/**
|
|
3867
|
+
* 会话行相关的共享取数(行尾徽标 + 宿主 hover 信息卡两个槽位共用)。
|
|
3868
|
+
*
|
|
3869
|
+
* 返回该行的会话 id 与生效人设;数据没拉过时顺手补拉一次。
|
|
3870
|
+
* 抽成一处是因为两个槽位读的是同一份数据、同一套降级规则 ——
|
|
3871
|
+
* 各写一份迟早出现「徽标有、hover 卡没有」这类分叉。
|
|
3872
|
+
*/
|
|
3873
|
+
function useSessionRowPersona(props) {
|
|
3874
|
+
const { useSoulController, controller } = props
|
|
3875
|
+
const state = useSoulController((s) => s)
|
|
3876
|
+
const enabled = state.enabled === true
|
|
3877
|
+
|
|
3878
|
+
// sessionId 的两种注入形态都接住:
|
|
3879
|
+
// ① 宿主把 owner props(SessionRowOwnerProps)并入组件 props —— 文档行为;
|
|
3880
|
+
// ② 若某个宿主版本改把它交给 inject,则由 inject 透传(见注册处)。
|
|
3881
|
+
const sessionId = typeof props.sessionId === 'string' && props.sessionId !== ''
|
|
3882
|
+
? props.sessionId
|
|
3883
|
+
: null
|
|
3884
|
+
|
|
3885
|
+
// 侧栏可能先于插件详情页挂载:那时 store.bindings 还是 null(从没拉过),
|
|
3886
|
+
// bindingsSessions 恒为空 ⇒ 两个槽位都渲染 null(用户实测「看不到」的成因)。
|
|
3887
|
+
// 这里补一次拉取:null 才拉,拉过(含「确实没有绑定」)就交给详情页那只。
|
|
3888
|
+
React.useEffect(() => {
|
|
3889
|
+
if (!enabled) return
|
|
3890
|
+
if (state.bindings !== null) return
|
|
3891
|
+
if (sidebarBindingsRequest) return
|
|
3892
|
+
if (!controller || typeof controller.loadBindings !== 'function') return
|
|
3893
|
+
sidebarBindingsRequest = Promise.resolve(controller.loadBindings())
|
|
3894
|
+
.catch(() => {})
|
|
3895
|
+
.finally(() => { sidebarBindingsRequest = null })
|
|
3896
|
+
}, [enabled, state.bindings])
|
|
3897
|
+
|
|
3898
|
+
const row = sessionId === null
|
|
3899
|
+
? null
|
|
3900
|
+
: (state.bindingsSessions || []).find((x) => x && x.id === sessionId)
|
|
3901
|
+
const persona = row && typeof row.persona === 'string' && row.persona !== ''
|
|
3902
|
+
? row.persona
|
|
3903
|
+
: null
|
|
3904
|
+
return { enabled, sessionId, persona }
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
function SoulSessionPersonaBadge(props) {
|
|
3908
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
3909
|
+
const { enabled, persona } = useSessionRowPersona(props)
|
|
3910
|
+
// 无会话 / 人设未知 / 个性化关闭 ⇒ 不渲染(不占 hover 位)
|
|
3911
|
+
if (!enabled || !persona) return null
|
|
3912
|
+
|
|
3913
|
+
const label = t('sidebar.personaTip', { name: persona })
|
|
3914
|
+
|
|
3915
|
+
// 气泡用**宿主自己的 Tooltip**(0.9.0-ak),参数照抄宿主的行尾按钮
|
|
3916
|
+
// (PinSessionRowButton):side bottom / align end / delayMs 500 / portal。
|
|
3917
|
+
// 之前用原生 title,浏览器画的是浅色系统气泡,和旁边「置顶/归档」的黑色
|
|
3918
|
+
// 气泡并排时两种样式 —— 用户实测指出不一致。
|
|
3919
|
+
//
|
|
3920
|
+
// 不用 title 还有第二个理由:人设也显示在宿主的 hover 信息卡里
|
|
3921
|
+
// (sidebar.session.row.hover),title 会让两个弹窗先后冒出来。
|
|
3922
|
+
const body = e('span', {
|
|
3923
|
+
className: 'soul-sidebar-persona-badge',
|
|
3924
|
+
// 只读标识:不做动作(点击仍会落在行上,避免误触打开会话)
|
|
3925
|
+
'aria-label': label
|
|
3926
|
+
},
|
|
3927
|
+
// 尺寸与邻居一致:宿主行尾图标是 14
|
|
3928
|
+
soulMask({ className: 'soul-sidebar-persona-badge-icon', width: 14, height: 14 })
|
|
3929
|
+
)
|
|
3930
|
+
// 降级:宿主若没有导出 Tooltip,就只渲染徽标(没有气泡,但绝不能整个塌掉)
|
|
3931
|
+
if (!ui || ui.Tooltip === undefined || ui.Tooltip === null) return body
|
|
3932
|
+
return e(ui.Tooltip, {
|
|
3933
|
+
label,
|
|
3934
|
+
side: 'bottom',
|
|
3935
|
+
align: 'end',
|
|
3936
|
+
delayMs: 500,
|
|
3937
|
+
portal: true
|
|
3938
|
+
}, body)
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
// -------------------------------------------------------------------------
|
|
3942
|
+
// 宿主 hover 信息卡里的「人设」一行(0.9.0-aj)
|
|
3943
|
+
// -------------------------------------------------------------------------
|
|
3944
|
+
//
|
|
3945
|
+
// 槽位:sidebar.session.row.hover —— kind:'list' / scope:'root',宿主文档:
|
|
3946
|
+
// "Section of the Session row's hover card between its relative time and its
|
|
3947
|
+
// trailing status line. Mounted only while that card is open."
|
|
3948
|
+
// ui-workspace 自己的自动任务摘要(schedule-tasks)也在这里。
|
|
3949
|
+
//
|
|
3950
|
+
// 为什么放这里:宿主 hover 会话行会弹出信息卡(标题 / 运行状态 / 几分钟前)。
|
|
3951
|
+
// 人设属于同一类「这一行现在是什么状态」的信息,塞进宿主的卡里比另起一个
|
|
3952
|
+
// 自绘 tooltip 更一致 —— 用户只看到一张卡,而不是两张轮流冒出来。
|
|
3953
|
+
// 卡片只在打开时挂载 ⇒ 这里的取数是真正的按需,不占常驻开销。
|
|
3954
|
+
function SoulSessionPersonaHover(props) {
|
|
3955
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
3956
|
+
const { enabled, persona } = useSessionRowPersona(props)
|
|
3957
|
+
if (!enabled || !persona) return null
|
|
3958
|
+
// 结构照抄宿主同槽位住户(ui-schedule 的 SessionScheduleHover):
|
|
3959
|
+
// 一行 = 图标容器 + 说明 + 主体值,行高/字号/间距都与宿主卡片一致。
|
|
3960
|
+
return e('div', { className: 'soul-hover-persona' },
|
|
3961
|
+
e('span', { className: 'soul-hover-persona-icon', 'aria-hidden': true },
|
|
3962
|
+
soulMask({ width: 12, height: 12 })),
|
|
3963
|
+
e('span', { className: 'soul-hover-persona-label' }, t('sidebar.personaRow')),
|
|
3964
|
+
e('span', { className: 'soul-hover-persona-name', title: persona }, persona)
|
|
1316
3965
|
)
|
|
1317
3966
|
}
|
|
1318
3967
|
|
|
@@ -1323,9 +3972,6 @@ window.__ModuleLoader__.load({
|
|
|
1323
3972
|
const state = useSoulController((state) => state)
|
|
1324
3973
|
const enabled = state.enabled === true
|
|
1325
3974
|
const busy = state.saving === true
|
|
1326
|
-
// B:底栏开关上缀当前人设名,与输入框上方的标签互相印证
|
|
1327
|
-
const activePersona = state.activePersona
|
|
1328
|
-
|
|
1329
3975
|
// 保存失败的本地标记:失败时 store.error 会带上原因,但按钮本身此前毫无反馈
|
|
1330
3976
|
// (enabled 没变 ⇒ 外观与文案都不变),用户视角是「点了没反应」。
|
|
1331
3977
|
const [failed, setFailed] = React.useState(false)
|
|
@@ -1360,17 +4006,331 @@ window.__ModuleLoader__.load({
|
|
|
1360
4006
|
// 「未匹配预设」这种说法听着像报错。底栏这里是输入框旁唯一的常驻状态,
|
|
1361
4007
|
// 必须给确定的落点 —— 有名字就是那个预设,没名字就是自定义。
|
|
1362
4008
|
// 关闭个性化时不显示(人设此时不生效,挂名字会误导)。
|
|
1363
|
-
|
|
1364
|
-
|
|
4009
|
+
// 人设名与层级标注**不在这里**(0.9.0-h 挪到会话 header):
|
|
4010
|
+
// 输入框左侧横向空间有限,那串文字会被裁掉;header 那一行才有空间。
|
|
4011
|
+
// 这里只留「开/关」这个动作。
|
|
4012
|
+
)
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
// -------------------------------------------------------------------------
|
|
4016
|
+
// 设置页「工作区绑定」分组(0.8.1)
|
|
4017
|
+
// -------------------------------------------------------------------------
|
|
4018
|
+
|
|
4019
|
+
// 目录点选服务。由 apply() 里的 ctx.inject(['uiWorkspace'], ...) 动态写入 ——
|
|
4020
|
+
// **不能**把它加进静态 inject 数组(`const inject = ['slots','locale']`):
|
|
4021
|
+
// uiWorkspace 缺失时整个插件都不会激活,设置页连同人设卡一起消失。
|
|
4022
|
+
// null = 这个连接拿不到该服务,界面直接走手输路径。
|
|
4023
|
+
let directoryPicker = null
|
|
4024
|
+
|
|
4025
|
+
// 路径截中段显示:保留结尾(`…\dsh-plugin`)。用户靠结尾区分项目、靠开头区分盘符,
|
|
4026
|
+
// 两者都要时优先结尾 —— 同一盘下的项目前缀往往一样长。完整路径交给 title。
|
|
4027
|
+
function shortenPath(raw, max) {
|
|
4028
|
+
const text = typeof raw === 'string' ? raw : String(raw == null ? '' : raw)
|
|
4029
|
+
const limit = Number.isFinite(max) ? max : 44
|
|
4030
|
+
if (text.length <= limit) return text
|
|
4031
|
+
const tail = text.slice(text.length - (limit - 1))
|
|
4032
|
+
// 从第一个分隔符处切,避免留下半截目录名(`…-plugin` 比 `…\dsh-plugin` 难认)
|
|
4033
|
+
const cut = Math.max(tail.indexOf('\\'), tail.indexOf('/'))
|
|
4034
|
+
return '…' + (cut >= 0 ? tail.slice(cut) : tail)
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
// 「工作区绑定」主体。三块结构见 docs/DESIGN-persona-scope.md §7.3:
|
|
4038
|
+
// ① 当前会话目录卡(我这个项目现在是谁)② 绑定列表(增删改查)③ 未绑定回落行。
|
|
4039
|
+
//
|
|
4040
|
+
// 与变更记录同款**懒加载**:本组件只在分组展开时才被渲染(手风琴 body 的
|
|
4041
|
+
// `open && ...`),所以挂载即等于「用户主动打开了它」,effect 里拉一次即可。
|
|
4042
|
+
// 刻意不进 2 秒轮询 —— 绑定是配置不是状态,轮询只会让「手机上改了、桌面上跳」。
|
|
4043
|
+
function SoulBindings(props) {
|
|
4044
|
+
const { useSoulController, controller, personaNames, onOpenPersonas } = props
|
|
4045
|
+
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
4046
|
+
const state = useSoulController((state) => state)
|
|
4047
|
+
const {
|
|
4048
|
+
bindings, bindingsInvalid, bindingsDropped, bindingsWarning, bindingsError,
|
|
4049
|
+
bindingsMax, bindingsMaxPath, bindingsSessions, activePersona
|
|
4050
|
+
} = state
|
|
4051
|
+
|
|
4052
|
+
// 下拉选项:内置优先的稳定顺序由 SoulSettings 算好传进来(同一份排序)。
|
|
4053
|
+
const names = Array.isArray(personaNames) ? personaNames : []
|
|
4054
|
+
const [draftDir, setDraftDir] = React.useState('')
|
|
4055
|
+
const [draftPersona, setDraftPersona] = React.useState('')
|
|
4056
|
+
const [pickerFailed, setPickerFailed] = React.useState(false)
|
|
4057
|
+
const [pendingUnbind, setPendingUnbind] = React.useState(null)
|
|
4058
|
+
const [notice, setNotice] = React.useState(null)
|
|
4059
|
+
const [errorNotice, setErrorNotice] = React.useState(null)
|
|
4060
|
+
|
|
4061
|
+
React.useEffect(() => {
|
|
4062
|
+
if (!controller.canRefresh()) return
|
|
4063
|
+
void controller.loadBindings()
|
|
4064
|
+
}, [])
|
|
4065
|
+
|
|
4066
|
+
// 解绑的二次确认 5 秒后自动复位(与删除预设同一套 armed 态,不用原生 confirm)
|
|
4067
|
+
React.useEffect(() => {
|
|
4068
|
+
if (pendingUnbind === null) return undefined
|
|
4069
|
+
const timer = setTimeout(() => setPendingUnbind(null), 5000)
|
|
4070
|
+
return () => clearTimeout(timer)
|
|
4071
|
+
}, [pendingUnbind])
|
|
4072
|
+
|
|
4073
|
+
const showError = (text) => { setErrorNotice(text); setNotice(null) }
|
|
4074
|
+
const showNotice = (text) => { setNotice(text); setErrorNotice(null) }
|
|
4075
|
+
|
|
4076
|
+
// 草稿人设默认取列表第一项 —— 下拉不能空着等用户点,那看着像坏了
|
|
4077
|
+
const effectiveDraftPersona = names.includes(draftPersona)
|
|
4078
|
+
? draftPersona
|
|
4079
|
+
: (names.length > 0 ? names[0] : '')
|
|
4080
|
+
|
|
4081
|
+
// 调起系统目录选择器。取消返回空串(什么都不做),失败抛错。
|
|
4082
|
+
//
|
|
4083
|
+
// 降级判据必须是「调用失败」而不是「预读后端种类」:宿主 UiWorkspaceService
|
|
4084
|
+
// 的 pickDirectory() 把底层错误(含 capability 字段)包装成了普通 Error,
|
|
4085
|
+
// 客户端读不到种类。见 docs/DESIGN-persona-scope.md §7.5。
|
|
4086
|
+
async function handlePick() {
|
|
4087
|
+
try {
|
|
4088
|
+
// 防御:服务可能在按钮渲染后被撤走(插件卸载 / scope 物化),此时按「不可用」处理
|
|
4089
|
+
if (!directoryPicker) throw new Error(t('bindings.pickerUnavailable'))
|
|
4090
|
+
const picked = await directoryPicker.pickDirectory()
|
|
4091
|
+
// null = 用户取消:不是错误,不该弹红字
|
|
4092
|
+
if (typeof picked === 'string' && picked !== '') setDraftDir(picked)
|
|
4093
|
+
} catch (err) {
|
|
4094
|
+
setPickerFailed(true)
|
|
4095
|
+
showError(messageOf(err))
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
async function handleBind() {
|
|
4100
|
+
const dir = draftDir.trim()
|
|
4101
|
+
if (dir === '') {
|
|
4102
|
+
showError(t('bindings.needDir'))
|
|
4103
|
+
return
|
|
4104
|
+
}
|
|
4105
|
+
try {
|
|
4106
|
+
await controller.saveBinding(dir, effectiveDraftPersona)
|
|
4107
|
+
setDraftDir('')
|
|
4108
|
+
showNotice(t('bindings.bound', { dir: shortenPath(dir), name: effectiveDraftPersona }))
|
|
4109
|
+
} catch (err) {
|
|
4110
|
+
// 服务端的拒绝文案可以直接展示(它已经写清了是哪一类问题),不再包一层前缀
|
|
4111
|
+
showError(messageOf(err))
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
async function handleRebind(dir, persona) {
|
|
4116
|
+
try {
|
|
4117
|
+
await controller.saveBinding(dir, persona)
|
|
4118
|
+
showNotice(t('bindings.bound', { dir: shortenPath(dir), name: persona }))
|
|
4119
|
+
} catch (err) {
|
|
4120
|
+
showError(messageOf(err))
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
async function handleUnbind(dir) {
|
|
4125
|
+
if (pendingUnbind !== dir) {
|
|
4126
|
+
setPendingUnbind(dir)
|
|
4127
|
+
setErrorNotice(null)
|
|
4128
|
+
return
|
|
4129
|
+
}
|
|
4130
|
+
setPendingUnbind(null)
|
|
4131
|
+
try {
|
|
4132
|
+
const payload = await controller.removeBinding(dir)
|
|
4133
|
+
// removed 为假 = 这条绑定已经不在表里了(另一个窗口删的),不必谎报成功
|
|
4134
|
+
if (payload && payload.removed === false) showError(t('bindings.unbindMissing'))
|
|
4135
|
+
else showNotice(t('bindings.unbound', { dir: shortenPath(dir) }))
|
|
4136
|
+
} catch (err) {
|
|
4137
|
+
showError(messageOf(err))
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
const table = bindings && typeof bindings === 'object' && !Array.isArray(bindings) ? bindings : {}
|
|
4142
|
+
const dirs = Object.keys(table)
|
|
4143
|
+
// 失效引用:服务端按当前预设库算好后标注,客户端只负责画出来(不自动删除 ——
|
|
4144
|
+
// 用户可能只是临时删了那个预设、打算改名回来)
|
|
4145
|
+
const invalidNames = new Map()
|
|
4146
|
+
for (const item of Array.isArray(bindingsInvalid) ? bindingsInvalid : []) {
|
|
4147
|
+
if (item && typeof item.dir === 'string') invalidNames.set(item.dir, String(item.persona))
|
|
4148
|
+
}
|
|
4149
|
+
// 层级排序:父目录排在子目录前,用 `└` 标出「这是上一条的子树」。
|
|
4150
|
+
// 同一个 `D:\项目A` 既绑了自身又绑了子目录时,先看父再看子才读得懂继承关系。
|
|
4151
|
+
const sortedDirs = dirs.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
4152
|
+
|
|
4153
|
+
// 当前会话:优先挑有 cwd 的那个 —— 没有目录的会话任何绑定都不会命中,
|
|
4154
|
+
// 拿它当「当前」会让整张卡说的都不是用户眼前这个项目。
|
|
4155
|
+
const sessions = Array.isArray(bindingsSessions) ? bindingsSessions : []
|
|
4156
|
+
const withCwd = sessions.filter((s) => s && typeof s.cwd === 'string' && s.cwd !== '')
|
|
4157
|
+
const current = withCwd.length > 0 ? withCwd[0] : (sessions.length > 0 ? sessions[0] : null)
|
|
4158
|
+
const currentCwd = current && typeof current.cwd === 'string' && current.cwd !== '' ? current.cwd : null
|
|
4159
|
+
const moreCount = sessions.length - (current ? 1 : 0)
|
|
4160
|
+
const binding = current && current.binding ? current.binding : null
|
|
4161
|
+
const effectivePersona = current && current.persona ? String(current.persona) : ''
|
|
4162
|
+
// 「直接绑在本目录」与「继承自上级」的区别只体现在这里:没有它,用户在子目录里
|
|
4163
|
+
// 看到别的人设会以为是 bug。判别已在宿主做过(客户端拿原始串比较会被盘符大小写骗过)。
|
|
4164
|
+
const sourceText = !binding
|
|
4165
|
+
? t('bindings.sourceGlobal')
|
|
4166
|
+
: (binding.inherited
|
|
4167
|
+
? t('bindings.sourceInherited', { dir: shortenPath(binding.dir) })
|
|
4168
|
+
: shortenPath(binding.dir))
|
|
4169
|
+
|
|
4170
|
+
return e('div', { className: 'soul-bindings' },
|
|
4171
|
+
// ① 当前会话目录卡 —— 先回答「我这个项目现在是谁」,再让用户改
|
|
4172
|
+
e('div', { className: 'soul-field' },
|
|
4173
|
+
e('div', { className: 'soul-field-label' }, t('bindings.currentTitle')),
|
|
4174
|
+
current === null
|
|
4175
|
+
? e('div', { className: 'soul-status' }, t('bindings.noSession'))
|
|
4176
|
+
: (currentCwd === null
|
|
4177
|
+
? e('div', { className: 'soul-status' }, t('bindings.noCwd'))
|
|
4178
|
+
: e('div', null,
|
|
4179
|
+
e('div', { className: 'soul-bindings-current' },
|
|
4180
|
+
e('code', {
|
|
4181
|
+
className: 'soul-bindings-path',
|
|
4182
|
+
title: currentCwd
|
|
4183
|
+
}, shortenPath(currentCwd)),
|
|
4184
|
+
e('div', { className: 'soul-bindings-meta' },
|
|
4185
|
+
e('div', null, `${t('bindings.effective')}:${effectivePersona || t('quick.custom')}`),
|
|
4186
|
+
e('div', null, `${t('bindings.source')}:${sourceText}`)
|
|
4187
|
+
)
|
|
4188
|
+
),
|
|
4189
|
+
moreCount > 0 && e('div', { className: 'soul-status' },
|
|
4190
|
+
t('bindings.moreSessions', { count: String(moreCount) }))
|
|
4191
|
+
))
|
|
4192
|
+
),
|
|
4193
|
+
|
|
4194
|
+
// ② 新建 / 改绑入口。点选与手输填的是**同一个字段** —— 选择器只是把路径
|
|
4195
|
+
// 填进来,不是另一条独立的路径,所以两者不会给出不同的结果。
|
|
4196
|
+
e('div', { className: 'soul-field' },
|
|
4197
|
+
e('label', { htmlFor: 'soul-bindings-dir' }, t('bindings.manualLabel')),
|
|
4198
|
+
e('div', { className: 'soul-bindings-new' },
|
|
4199
|
+
e('input', {
|
|
4200
|
+
type: 'text',
|
|
4201
|
+
id: 'soul-bindings-dir',
|
|
4202
|
+
value: draftDir,
|
|
4203
|
+
placeholder: t('bindings.manualPlaceholder'),
|
|
4204
|
+
onChange: (ev) => setDraftDir(ev.target.value)
|
|
4205
|
+
}),
|
|
4206
|
+
// 失败过一次就不再给按钮:宿主的能力在启动时判定一次、之后不会变,
|
|
4207
|
+
// 留着它只会让用户反复点一个确定会失败的东西。
|
|
4208
|
+
// 服务**压根没注入**时同理(那些连接方式拿不到 uiWorkspace):按钮不能出现,
|
|
4209
|
+
// 否则点下去只会在 handlePick 里对 null 取属性、抛一个用户看不懂的 TypeError。
|
|
4210
|
+
// 0.9.0-ah:改用宿主 ui.Button —— 原先自绘的 <button> 继承了宿主
|
|
4211
|
+
// 基础按钮样式(实心底 + 白字),我们又用 color:brand-primary 覆盖文字,
|
|
4212
|
+
// hover 时深底 + 深字 ⇒ 整个按钮全黑(用户反馈)。
|
|
4213
|
+
// 换成宿主组件后,底色 / 文字 / 悬停是一套,构造上必然与宿主一致。
|
|
4214
|
+
!pickerFailed && directoryPicker !== null && e(ui.Button, {
|
|
4215
|
+
variant: 'outline',
|
|
4216
|
+
onClick: () => { void handlePick() }
|
|
4217
|
+
}, t('bindings.choose'))
|
|
4218
|
+
),
|
|
4219
|
+
pickerFailed && e('div', { className: 'soul-bindings-hint' }, t('bindings.pickerUnavailable')),
|
|
4220
|
+
e('div', { className: 'soul-bindings-new' },
|
|
4221
|
+
e('label', { htmlFor: 'soul-bindings-persona' }, t('bindings.persona')),
|
|
4222
|
+
e('select', {
|
|
4223
|
+
id: 'soul-bindings-persona',
|
|
4224
|
+
value: effectiveDraftPersona,
|
|
4225
|
+
onChange: (ev) => setDraftPersona(ev.target.value)
|
|
4226
|
+
}, names.map((name) => e('option', { key: name, value: name }, name))),
|
|
4227
|
+
// variant 用 outline,**不用 primary**:primary 是实心主色
|
|
4228
|
+
// (浅色主题下 ≈ 黑底),hover 再压一层就更黑 —— 用户实测「全黑」。
|
|
4229
|
+
// outline 是描边按钮,hover 底色由宿主的 button token 决定,
|
|
4230
|
+
// 与设置页那些次要按钮(重置 / 存为预设)同族。
|
|
4231
|
+
e(ui.Button, {
|
|
4232
|
+
variant: 'outline',
|
|
4233
|
+
disabled: names.length === 0,
|
|
4234
|
+
onClick: () => { void handleBind() }
|
|
4235
|
+
}, t('bindings.bindDir'))
|
|
4236
|
+
),
|
|
4237
|
+
bindingsMaxPath && e('div', { className: 'soul-status' },
|
|
4238
|
+
t('bindings.maxPath', { max: String(bindingsMaxPath) }))
|
|
4239
|
+
),
|
|
4240
|
+
|
|
4241
|
+
// ③ 绑定列表
|
|
4242
|
+
e('div', { className: 'soul-field' },
|
|
4243
|
+
e('div', { className: 'soul-field-label' }, t('bindings.listTitle')),
|
|
4244
|
+
bindingsError
|
|
4245
|
+
? e('div', { className: 'soul-error' }, t('bindings.loadFailed', { reason: bindingsError }))
|
|
4246
|
+
: (bindings === null
|
|
4247
|
+
? e('div', { className: 'soul-status' }, t('bindings.loading'))
|
|
4248
|
+
: (sortedDirs.length === 0
|
|
4249
|
+
? e('div', { className: 'soul-status' }, t('bindings.empty'))
|
|
4250
|
+
: e('div', null,
|
|
4251
|
+
e('div', { className: 'soul-status' },
|
|
4252
|
+
t('bindings.count', {
|
|
4253
|
+
count: String(sortedDirs.length),
|
|
4254
|
+
max: Number.isFinite(bindingsMax) ? String(bindingsMax) : '—'
|
|
4255
|
+
})),
|
|
4256
|
+
e('ul', { className: 'soul-bindings-rows' },
|
|
4257
|
+
sortedDirs.map((dir, at) => {
|
|
4258
|
+
// 与前面渲染过的某条构成父子关系时画 `└`,把「一条绑定 =
|
|
4259
|
+
// 一棵子树」画出来 —— 否则用户看不出为什么两个目录会用同一个人设
|
|
4260
|
+
const parent = sortedDirs.slice(0, at).find((prev) => (
|
|
4261
|
+
dir.startsWith(prev + '\\') || dir.startsWith(prev + '/')
|
|
4262
|
+
))
|
|
4263
|
+
const isCurrent = currentCwd !== null && (
|
|
4264
|
+
currentCwd === dir ||
|
|
4265
|
+
currentCwd.startsWith(dir + '\\') ||
|
|
4266
|
+
currentCwd.startsWith(dir + '/')
|
|
4267
|
+
)
|
|
4268
|
+
const invalidName = invalidNames.get(dir)
|
|
4269
|
+
const value = invalidName || String(table[dir])
|
|
4270
|
+
const isInvalid = invalidName !== undefined
|
|
4271
|
+
return e('li', {
|
|
4272
|
+
key: dir,
|
|
4273
|
+
className: 'soul-bindings-row',
|
|
4274
|
+
'data-current': isCurrent ? 'true' : 'false'
|
|
4275
|
+
},
|
|
4276
|
+
parent && e('span', { className: 'soul-bindings-tree', 'aria-hidden': true }, '└'),
|
|
4277
|
+
e('code', { className: 'soul-bindings-dir', title: dir }, shortenPath(dir)),
|
|
4278
|
+
isCurrent && e('span', { className: 'soul-bindings-here' }, t('bindings.here')),
|
|
4279
|
+
isInvalid && e('span', { className: 'soul-bindings-bad' },
|
|
4280
|
+
t('bindings.invalid', { name: invalidName })),
|
|
4281
|
+
// 失效引用也画在下拉里(作为唯一选项):让用户看到「现在写的是谁」,
|
|
4282
|
+
// 而不是看见一个空下拉、以为这条记录没有值
|
|
4283
|
+
e('select', {
|
|
4284
|
+
className: 'soul-bindings-select',
|
|
4285
|
+
value,
|
|
4286
|
+
'aria-label': t('bindings.persona'),
|
|
4287
|
+
onChange: (ev) => { void handleRebind(dir, ev.target.value) }
|
|
4288
|
+
},
|
|
4289
|
+
isInvalid && e('option', { value }, invalidName),
|
|
4290
|
+
names.map((name) => e('option', { key: name, value: name }, name))
|
|
4291
|
+
),
|
|
4292
|
+
e(ui.Button, {
|
|
4293
|
+
// 待确认态靠文案已能区分(「确认解绑…」),
|
|
4294
|
+
// 红色描边是增强:className 若不透传也不影响可用性。
|
|
4295
|
+
className: pendingUnbind === dir ? 'soul-bindings-act-armed' : undefined,
|
|
4296
|
+
onClick: () => { void handleUnbind(dir) }
|
|
4297
|
+
}, pendingUnbind === dir ? t('bindings.unbindArmed') : t('bindings.unbind'))
|
|
4298
|
+
)
|
|
4299
|
+
})
|
|
4300
|
+
)
|
|
4301
|
+
))
|
|
4302
|
+
),
|
|
4303
|
+
|
|
4304
|
+
// ④ 未绑定回落行:把隐式行为显式写成一条可改的规则
|
|
4305
|
+
e('div', { className: 'soul-bindings-fallback' },
|
|
4306
|
+
e('span', { className: 'soul-bindings-fallback-text' },
|
|
4307
|
+
`${t('bindings.fallback')} · ${activePersona || t('quick.custom')}`),
|
|
4308
|
+
e(ui.Button, {
|
|
4309
|
+
onClick: onOpenPersonas
|
|
4310
|
+
}, t('bindings.change'))
|
|
4311
|
+
),
|
|
4312
|
+
|
|
4313
|
+
// 「看到的不是全部」与「失败了」是两件事:前者是黄字警告,后者是红字
|
|
4314
|
+
Number.isFinite(bindingsDropped) && bindingsDropped > 0 && e('div', { className: 'soul-status' },
|
|
4315
|
+
t('bindings.dropped', { count: String(bindingsDropped) })),
|
|
4316
|
+
bindingsWarning && e('div', { className: 'soul-status soul-bindings-warning' },
|
|
4317
|
+
t('bindings.warning', { reason: bindingsWarning })),
|
|
4318
|
+
errorNotice && e('p', { className: 'soul-error-notice', role: 'alert' }, errorNotice),
|
|
4319
|
+
!errorNotice && notice && e('p', {
|
|
4320
|
+
className: 'soul-saved-notice',
|
|
4321
|
+
role: 'status',
|
|
4322
|
+
'aria-live': 'polite'
|
|
4323
|
+
}, notice)
|
|
4324
|
+
)
|
|
1365
4325
|
)
|
|
1366
4326
|
}
|
|
1367
4327
|
|
|
1368
|
-
|
|
4328
|
+
function SoulSettings(props) {
|
|
1369
4329
|
const { useSoulController, controller } = props
|
|
1370
4330
|
// 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
|
|
1371
4331
|
const t = typeof props.t === 'function' ? props.t : FALLBACK_T
|
|
1372
4332
|
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
|
|
4333
|
+
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
4334
|
|
|
1375
4335
|
const [localEnabled, setLocalEnabled] = React.useState(enabled)
|
|
1376
4336
|
const [localNickname, setLocalNickname] = React.useState(nickname || '')
|
|
@@ -1388,6 +4348,10 @@ window.__ModuleLoader__.load({
|
|
|
1388
4348
|
const [localTrailColor, setLocalTrailColor] = React.useState(trailColor || TRAIL_COLOR_FALLBACK)
|
|
1389
4349
|
const [localTrailSpeed, setLocalTrailSpeed] = React.useState(trailSpeed || 'slow')
|
|
1390
4350
|
const [localTrailWidth, setLocalTrailWidth] = React.useState(trailWidth || 'thin')
|
|
4351
|
+
// 变更记录保留条数**不再是设置页的表单字段**(0.8.1 迁到插件详情页的变更记录
|
|
4352
|
+
// 区块,就地改、立刻落盘)。因此它不进 localMap / savedMap / dirty ——
|
|
4353
|
+
// 否则「在详情页改了容量」会让设置页的保存按钮莫名亮起,且点保存会把那份
|
|
4354
|
+
// 详情页刚改的值当成「用户在这里的编辑」再提交一次。
|
|
1391
4355
|
const [personaName, setPersonaName] = React.useState('')
|
|
1392
4356
|
// 「存为预设」的命名弹层是否打开。
|
|
1393
4357
|
//
|
|
@@ -1400,6 +4364,25 @@ window.__ModuleLoader__.load({
|
|
|
1400
4364
|
// ③ 「保存设置」与「保存当前为预设」两个保存并列,语义没区分;
|
|
1401
4365
|
// 且名字要先想好才能点(disabled 依赖非空),交互顺序也别扭。
|
|
1402
4366
|
const [showSavePersona, setShowSavePersona] = React.useState(false)
|
|
4367
|
+
// 「更新到预设」弹层(0.9.0-s):null = 关闭;字符串 = 正在选择目标预设名。
|
|
4368
|
+
//
|
|
4369
|
+
// 为什么单独一个入口而不复用「存为预设」:后者是**新建**(要名字),
|
|
4370
|
+
// 前者是**覆盖已有**(选名字)。两个动作的用户意图不同,
|
|
4371
|
+
// 混在一个入口里会让人分不清「我到底建了新的还是改了旧的」。
|
|
4372
|
+
//
|
|
4373
|
+
// 底层能力已经就位:savePersona(name, draft) 会存草稿,
|
|
4374
|
+
// 且重复门禁会**排除同名条目**(即"覆盖同名"= 更新,天然允许)。
|
|
4375
|
+
const [showUpdatePersona, setShowUpdatePersona] = React.useState(false)
|
|
4376
|
+
// 「存为预设」弹层里的「保存后立即应用」复选框(0.9.0-t)。
|
|
4377
|
+
//
|
|
4378
|
+
// 解决的问题:保存预设后表单里仍是那套未保存的编辑 ⇒ 底部同时显示
|
|
4379
|
+
// 「已保存预设「X」」和「有未保存的更改」两条,读起来矛盾(用户实测反馈)。
|
|
4380
|
+
//
|
|
4381
|
+
// 勾选 = 保存后把这套**应用为当前配置**(dirty 消失,★ 跟着这个预设走);
|
|
4382
|
+
// 不勾选 = 保存后把表单**回退到已保存配置**(例子里是自驱型协作者),
|
|
4383
|
+
// 即「我把这套存起来了,但现在不切过去」。
|
|
4384
|
+
// 默认不勾选:存预设与改当前配置是两件事,默认不该替用户静默改配置。
|
|
4385
|
+
const [applyAfterSave, setApplyAfterSave] = React.useState(false)
|
|
1403
4386
|
// 待删除的预设名(null = 没有待确认的删除)。
|
|
1404
4387
|
//
|
|
1405
4388
|
// 替代原生 window.confirm:原生模态框在 webview 里可能把文档焦点留在异常状态
|
|
@@ -1539,7 +4522,7 @@ window.__ModuleLoader__.load({
|
|
|
1539
4522
|
: a === b)
|
|
1540
4523
|
const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
|
|
1541
4524
|
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 =
|
|
4525
|
+
const dirty = FORM_FIELD_KEYS.some((key) => !sameValue(savedMap[key], localMap[key]))
|
|
1543
4526
|
|
|
1544
4527
|
// 宿主 savedNotice 语义:一旦用户开始新的编辑(dirty 变 true),
|
|
1545
4528
|
// 上一条「已保存」就过时了 ⇒ 清除。(effect 必须放在 dirty 定义之后:
|
|
@@ -1664,15 +4647,111 @@ window.__ModuleLoader__.load({
|
|
|
1664
4647
|
|
|
1665
4648
|
// 存为预设:把「关于你/特质」里的**当前设置**存成一个新预设。
|
|
1666
4649
|
// 名字来自命名弹层;成功后关闭弹层并清空输入。
|
|
4650
|
+
//
|
|
4651
|
+
// ⚠️ 必须把**本地草稿**一起提交(0.9.0-r):表单是「改完点保存才写回」的结构,
|
|
4652
|
+
// 用户改完风格与自定义指令、没点「保存设置」直接点「存为预设」时,若只传名字,
|
|
4653
|
+
// 服务端只能快照**已保存的旧配置** —— 存出来的预设还是上一个预设的内容
|
|
4654
|
+
// (用户实测确认的 bug)。
|
|
4655
|
+
//
|
|
4656
|
+
// 只传人设字段:预设不含「关于你」(昵称 / 职业 / 介绍),它们不属于预设。
|
|
1667
4657
|
const handleSavePersona = async () => {
|
|
1668
4658
|
const name = personaName.trim()
|
|
1669
4659
|
if (!name) return
|
|
1670
4660
|
try {
|
|
1671
|
-
|
|
4661
|
+
const draft = {}
|
|
4662
|
+
for (const key of SESSION_OVERRIDE_KEYS) {
|
|
4663
|
+
// SESSION_OVERRIDE_KEYS 与 PERSONA_FIELDS 同集合(都含 language 与
|
|
4664
|
+
// customInstructions,都不含「关于你」)—— 直接复用,不另造一份清单。
|
|
4665
|
+
if (key in localMap) draft[key] = localMap[key]
|
|
4666
|
+
}
|
|
4667
|
+
await controller.savePersona(name, draft)
|
|
4668
|
+
|
|
4669
|
+
// 保存之后必须消解「已保存预设 X」与「有未保存更改」并存的矛盾(0.9.0-t):
|
|
4670
|
+
// 两条提示同时出现,读起来像"我到底存没存"。
|
|
4671
|
+
//
|
|
4672
|
+
// 勾选 ⇒ 把这套**应用为当前配置**(dirty 消失,★ 指向新预设);
|
|
4673
|
+
// 不勾选 ⇒ 把表单**回退到已保存配置**(丢弃这次编辑的表单值)。
|
|
4674
|
+
if (applyAfterSave) {
|
|
4675
|
+
await controller.usePersona(name)
|
|
4676
|
+
// usePersona 里 applyConfig 会把 store 更新成该预设的值;
|
|
4677
|
+
// 表单由下面的 effect 逐字段跟随(userEdited 判据会让"用户编辑过"的字段保留,
|
|
4678
|
+
// 但这里我们刚把 store 改成与 draft 一致,故不会残留 dirty)。
|
|
4679
|
+
await loadPersonas()
|
|
4680
|
+
showSavedNotice(t('notice.personaSavedAndApplied', { name }))
|
|
4681
|
+
} else {
|
|
4682
|
+
// 回退表单:把 lastSyncRef 清空 ⇒ userEdited 一律视为"未编辑" ⇒
|
|
4683
|
+
// 下一次 effect 用 store(已保存配置)覆盖全部表单字段。
|
|
4684
|
+
lastSyncRef.current = null
|
|
4685
|
+
// 直接把本地状态写回已保存配置,保证本帧就清掉 dirty(不等 effect)。
|
|
4686
|
+
setLocalEnabled(enabled)
|
|
4687
|
+
setLocalNickname(nickname || '')
|
|
4688
|
+
setLocalOccupation(occupation || '')
|
|
4689
|
+
setLocalBio(bio || '')
|
|
4690
|
+
setLocalStyle(style)
|
|
4691
|
+
setLocalHeadingLists(headingLists || 'default')
|
|
4692
|
+
setLocalEmoji(emoji || 'default')
|
|
4693
|
+
setLocalTables(tables || 'default')
|
|
4694
|
+
setLocalReplyLength(replyLength || 'normal')
|
|
4695
|
+
setLocalLanguage(language || 'zh')
|
|
4696
|
+
setLocalInstructions(customInstructions || '')
|
|
4697
|
+
setLocalToolConfirm(requireToolConfirmation === true)
|
|
4698
|
+
setLocalTrailEnabled(trailEnabled)
|
|
4699
|
+
setLocalTrailColor(trailColor)
|
|
4700
|
+
setLocalTrailSpeed(trailSpeed)
|
|
4701
|
+
setLocalTrailWidth(trailWidth)
|
|
4702
|
+
await loadPersonas()
|
|
4703
|
+
showSavedNotice(t('notice.personaSaved', { name }))
|
|
4704
|
+
}
|
|
4705
|
+
|
|
1672
4706
|
setPersonaName('')
|
|
4707
|
+
setApplyAfterSave(false)
|
|
1673
4708
|
setShowSavePersona(false)
|
|
4709
|
+
} catch (err) {
|
|
4710
|
+
showError(messageOf(err))
|
|
4711
|
+
}
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4714
|
+
// 更新到已有预设:把**当前草稿**覆盖写进某个已有预设(0.9.0-s)。
|
|
4715
|
+
//
|
|
4716
|
+
// 与 handleSavePersona 的唯一差别是「名字已存在」—— 底层是同一个
|
|
4717
|
+
// controller.savePersona(name, draft):重复门禁会排除同名条目,
|
|
4718
|
+
// 所以「覆盖自己」天然被允许,不需要额外的 force 参数。
|
|
4719
|
+
//
|
|
4720
|
+
// 成功后**不动活动配置**:这是「把当前这套存回某个预设」,
|
|
4721
|
+
// 不是「应用某个预设」。用户改的配置仍然是当前配置。
|
|
4722
|
+
const handleUpdatePersona = async (name) => {
|
|
4723
|
+
if (!name) return
|
|
4724
|
+
try {
|
|
4725
|
+
const draft = {}
|
|
4726
|
+
for (const key of SESSION_OVERRIDE_KEYS) {
|
|
4727
|
+
if (key in localMap) draft[key] = localMap[key]
|
|
4728
|
+
}
|
|
4729
|
+
await controller.savePersona(name, draft)
|
|
4730
|
+
|
|
4731
|
+
// 表单回退到**当前配置**:更新的是某个预设,不是当前配置,
|
|
4732
|
+
// 所以那套编辑不该继续挂在表单上(否则「已更新预设」+「有未保存的更改」
|
|
4733
|
+
// 并存,仍然是矛盾提示)。
|
|
4734
|
+
lastSyncRef.current = null
|
|
4735
|
+
setLocalEnabled(enabled)
|
|
4736
|
+
setLocalNickname(nickname || '')
|
|
4737
|
+
setLocalOccupation(occupation || '')
|
|
4738
|
+
setLocalBio(bio || '')
|
|
4739
|
+
setLocalStyle(style)
|
|
4740
|
+
setLocalHeadingLists(headingLists || 'default')
|
|
4741
|
+
setLocalEmoji(emoji || 'default')
|
|
4742
|
+
setLocalTables(tables || 'default')
|
|
4743
|
+
setLocalReplyLength(replyLength || 'normal')
|
|
4744
|
+
setLocalLanguage(language || 'zh')
|
|
4745
|
+
setLocalInstructions(customInstructions || '')
|
|
4746
|
+
setLocalToolConfirm(requireToolConfirmation === true)
|
|
4747
|
+
setLocalTrailEnabled(trailEnabled)
|
|
4748
|
+
setLocalTrailColor(trailColor)
|
|
4749
|
+
setLocalTrailSpeed(trailSpeed)
|
|
4750
|
+
setLocalTrailWidth(trailWidth)
|
|
4751
|
+
|
|
4752
|
+
setShowUpdatePersona(false)
|
|
1674
4753
|
await loadPersonas()
|
|
1675
|
-
showSavedNotice(t('notice.
|
|
4754
|
+
showSavedNotice(t('notice.personaUpdated', { name }))
|
|
1676
4755
|
} catch (err) {
|
|
1677
4756
|
showError(messageOf(err))
|
|
1678
4757
|
}
|
|
@@ -1730,38 +4809,13 @@ window.__ModuleLoader__.load({
|
|
|
1730
4809
|
return () => clearTimeout(timer)
|
|
1731
4810
|
}, [pendingDelete])
|
|
1732
4811
|
|
|
1733
|
-
// ——
|
|
4812
|
+
// —— 卡背(详情面)用到的辅助 ——
|
|
1734
4813
|
//
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
// 就是 'default',而卡背要回答的是「这个人设改了什么」—— 统一标成「默认」比留空
|
|
1741
|
-
// 更清楚(留空会让人以为渲染坏了)。
|
|
1742
|
-
// 语言维度复用 LANGUAGE_OPTIONS 的标签('中文' / 'English' 两种语言下同名,
|
|
1743
|
-
// 本来就是语言自称,无需翻译)。
|
|
1744
|
-
const personaDimLabel = (entry, dim) => {
|
|
1745
|
-
const row = entry || {}
|
|
1746
|
-
const value = row[dim]
|
|
1747
|
-
if (dim === 'language') {
|
|
1748
|
-
const opt = LANGUAGE_OPTIONS.find((o) => o.value === value)
|
|
1749
|
-
return opt ? opt.label : t('personas.dimDefault')
|
|
1750
|
-
}
|
|
1751
|
-
if (dim === 'style') {
|
|
1752
|
-
return STYLE_VALUES.includes(value) ? t(`style.${value}`) : t('personas.dimDefault')
|
|
1753
|
-
}
|
|
1754
|
-
return TRAIT_VALUES.includes(value) ? t(`trait.${dim}.${value}`) : t('personas.dimDefault')
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
// 卡背的全文:这一次**不截断**(正面效果栏才截断)。
|
|
1758
|
-
// 预设没写自定义指令时给一句说明,而不是留空 —— 留空会让人以为渲染坏了。
|
|
1759
|
-
const personaFullText = (entry) => {
|
|
1760
|
-
const raw = typeof (entry || {}).customInstructions === 'string'
|
|
1761
|
-
? entry.customInstructions.trim()
|
|
1762
|
-
: ''
|
|
1763
|
-
return raw === '' ? t('personas.noCustomInstructions') : raw
|
|
1764
|
-
}
|
|
4814
|
+
// 0.9.0-d 起这三样提到**模块级**(personaDimLabelOf / personaFullTextOf /
|
|
4815
|
+
// PERSONA_DIM_KEYS 已在模块级):会话「人设」tab 的右侧详情面板要显示与设置页
|
|
4816
|
+
// 卡背**逐字相同**的维度与全文,各写一份必然漂移。
|
|
4817
|
+
const personaDimLabel = (entry, dim) => personaDimLabelOf(entry, dim, t)
|
|
4818
|
+
const personaFullText = (entry) => personaFullTextOf(entry, t)
|
|
1765
4819
|
|
|
1766
4820
|
const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
|
|
1767
4821
|
const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
|
|
@@ -1778,6 +4832,12 @@ window.__ModuleLoader__.load({
|
|
|
1778
4832
|
})
|
|
1779
4833
|
: null
|
|
1780
4834
|
|
|
4835
|
+
// 可被「更新到预设」覆盖的预设 = **自建**的那些(0.9.0-s)。
|
|
4836
|
+
// 内置预设不可改:五条写路径都拒绝内置名,列出来只会让用户点了吃一个 400。
|
|
4837
|
+
const editablePersonaNames = (personaNames || []).filter(
|
|
4838
|
+
(name) => !(personas && personas[name] && personas[name].builtin)
|
|
4839
|
+
)
|
|
4840
|
+
|
|
1781
4841
|
// 展开「人设预设」分组时,轮播自动定位到**当前生效**的那张,而不是永远停在第 1 张。
|
|
1782
4842
|
//
|
|
1783
4843
|
// 为什么需要:卡片一次只展示一张,若固定从第 1 张开始,用户点开分组后要翻好几下
|
|
@@ -1818,6 +4878,20 @@ window.__ModuleLoader__.load({
|
|
|
1818
4878
|
const personasSummary = personasError
|
|
1819
4879
|
? t('accordion.loadFailed')
|
|
1820
4880
|
: (personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`)
|
|
4881
|
+
// 工作区绑定摘要:折叠态只看得到这一行,所以要先说「读没读出来」,
|
|
4882
|
+
// 再说条数。null = 还没拉过(本分组懒加载,展开前一直是 null)⇒ 显示空态文案,
|
|
4883
|
+
// 不显示「0 条」—— 那会把「没加载」误报成「一条都没有」。
|
|
4884
|
+
//
|
|
4885
|
+
// 0.8.1 分组已从设置页撤下,这里暂时无人引用;保留是因为 0.9.0 的工作区
|
|
4886
|
+
// 平铺卡片要用同一套「摘要怎么说」的判断(加载中 / 失败 / 0 条 / N 条)。
|
|
4887
|
+
// 删掉它只会让那段逻辑在 0.9.0 被重写一遍,而那正是容易漏掉「加载中≠空」的地方。
|
|
4888
|
+
const bindingsSummary = bindingsError
|
|
4889
|
+
? t('accordion.loadFailed')
|
|
4890
|
+
: (bindings === null || !bindings || typeof bindings !== 'object' || Array.isArray(bindings)
|
|
4891
|
+
? t('bindings.empty')
|
|
4892
|
+
: (Object.keys(bindings).length === 0
|
|
4893
|
+
? t('bindings.empty')
|
|
4894
|
+
: t('bindings.count', { count: String(Object.keys(bindings).length), max: Number.isFinite(bindingsMax) ? String(bindingsMax) : '—' })))
|
|
1821
4895
|
const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
|
|
1822
4896
|
const promptSummary = promptText.length > 0
|
|
1823
4897
|
? t(promptIsDraft ? 'prompt.summaryDraft' : 'prompt.summaryChars', { n: String(promptText.length) })
|
|
@@ -1830,33 +4904,12 @@ window.__ModuleLoader__.load({
|
|
|
1830
4904
|
// 预设只描述 Agent 的人格,不含「关于你」(昵称 / 职业 / 介绍)—— 那是使用者
|
|
1831
4905
|
// 的身份信息,不随预设切换。所以摘要里不再出现昵称,剩下的恰好也是比较两个
|
|
1832
4906
|
// 预设时最需要看到的信息。
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
const value = row[field]
|
|
1840
|
-
if (!value || value === META_FIELD_DEFAULTS[field]) continue
|
|
1841
|
-
if (!allowed.includes(value)) continue
|
|
1842
|
-
parts.push(t(`meta.${field}.${value}`))
|
|
1843
|
-
}
|
|
1844
|
-
return parts.join(' · ')
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
// 卡牌效果栏文本:优先用预设的 customInstructions(它是人格差异的主要载体),
|
|
1848
|
-
// 没有则退回维度摘要。为什么取 customInstructions 而不是摘要:摘要只说
|
|
1849
|
-
// 「专业严谨 · 默认」这类档位,而卡牌需要一句话说清「这个预设会怎么回答」。
|
|
1850
|
-
//
|
|
1851
|
-
// 截断到第一句或 60 字:卡面空间有限,CSS 侧还有 3 行 line-clamp 兜底。
|
|
1852
|
-
const personaEffectText = (entry) => {
|
|
1853
|
-
const raw = typeof (entry || {}).customInstructions === 'string' ? entry.customInstructions.trim() : ''
|
|
1854
|
-
if (raw === '') return personaRowMeta(entry)
|
|
1855
|
-
// 按中文句号/分号/换行切第一句;无标点时整段交给 CSS 截断
|
|
1856
|
-
const firstSentence = raw.split(/[。;;\n]/)[0] || raw
|
|
1857
|
-
const text = firstSentence.length > 60 ? `${firstSentence.slice(0, 60)}…` : firstSentence
|
|
1858
|
-
return text
|
|
1859
|
-
}
|
|
4907
|
+
//
|
|
4908
|
+
// 0.9.0 起这两个函数提到**模块级**(personaRowMetaOf / personaEffectTextOf):
|
|
4909
|
+
// 会话「人设」tab 与工作区卡片也要用同一种措辞 —— 同一个预设在两处显示不同的
|
|
4910
|
+
// 摘要,用户会以为它们不是同一个东西。这里保留同名薄包装,避免改动大量调用点。
|
|
4911
|
+
const personaRowMeta = (entry) => personaRowMetaOf(entry, t)
|
|
4912
|
+
const personaEffectText = (entry) => personaEffectTextOf(entry, t)
|
|
1860
4913
|
|
|
1861
4914
|
return e('div', { className: 'soul-section' },
|
|
1862
4915
|
e('div', { className: 'soul-title-row' },
|
|
@@ -2019,6 +5072,9 @@ window.__ModuleLoader__.load({
|
|
|
2019
5072
|
open: openSection === 'personas',
|
|
2020
5073
|
onToggle: toggleSection('personas')
|
|
2021
5074
|
},
|
|
5075
|
+
// 保留条数下拉不在这里 —— 0.8.1 起它跟着变更记录走,落在**插件详情页**
|
|
5076
|
+
// 的变更记录区块里(容量参数与被它约束的内容放在一起才说得通)。
|
|
5077
|
+
// 设置页这一组只负责预设本身与那句去向指引。
|
|
2022
5078
|
e('div', { className: 'soul-field' },
|
|
2023
5079
|
// 加载失败优先于「加载中」:否则失败时界面永远停在加载态,与「还没有预设」无从区分
|
|
2024
5080
|
personasError
|
|
@@ -2127,7 +5183,12 @@ window.__ModuleLoader__.load({
|
|
|
2127
5183
|
// 名称与摘要压在图上
|
|
2128
5184
|
e('div', { className: 'soul-persona-art-title' },
|
|
2129
5185
|
e('span', { className: 'soul-persona-name' },
|
|
2130
|
-
e('span', { className: 'soul-persona-label' }, name === activePersona ? `★ ${name}` : name)
|
|
5186
|
+
e('span', { className: 'soul-persona-label' }, name === activePersona ? `★ ${name}` : name),
|
|
5187
|
+
// 作用域标注:设置页的「使用」写的是**全局**(settings.section
|
|
5188
|
+
// 拿不到 sessionId,这是宿主槽位契约决定的)。标出来是为了
|
|
5189
|
+
// 避免用户以为「在这里点了使用 = 只影响这个项目」——
|
|
5190
|
+
// 三级隔离下那是会话/工作区层的事。
|
|
5191
|
+
e('span', { className: 'soul-persona-scope-tag' }, t('personas.scopeGlobal'))
|
|
2131
5192
|
),
|
|
2132
5193
|
e('div', { className: 'soul-persona-meta' }, personaRowMeta(entry))
|
|
2133
5194
|
)
|
|
@@ -2289,12 +5350,50 @@ window.__ModuleLoader__.load({
|
|
|
2289
5350
|
e('button', {
|
|
2290
5351
|
type: 'button',
|
|
2291
5352
|
className: 'soul-drift-action',
|
|
2292
|
-
onClick: () => { setPersonaName(''); setShowSavePersona(true) }
|
|
5353
|
+
onClick: () => { setPersonaName(''); setApplyAfterSave(false); setShowSavePersona(true) }
|
|
2293
5354
|
}, t('personas.driftAction'))
|
|
2294
5355
|
),
|
|
2295
|
-
|
|
5356
|
+
// —— 说明:一条轻提示 + 一个可点的去向,不再铺两行正文 ——
|
|
5357
|
+
//
|
|
5358
|
+
// 原来这里是两行灰字,每行都在复述用户已经能从界面里看到的东西:
|
|
5359
|
+
// ·「风格 / 特质 / 回复长度 / 输出语言 / 自定义指令」—— 就是卡片上那几行;
|
|
5360
|
+
// ·「内置预设不可删除」—— 每张内置卡都有「内置」徽标、且没有删除按钮;
|
|
5361
|
+
// ·「撤销、恢复预设、清空都在那里」—— 是详情页上三个按钮的名字。
|
|
5362
|
+
// 名词语气+穷举,读起来像条款。现在只留**用户真正会问的那一个判断**
|
|
5363
|
+
//(「存这个会不会把我的昵称也换掉」),其余降级为问号里的 hover 全文。
|
|
5364
|
+
e('div', { className: 'soul-note soul-note-inline' },
|
|
5365
|
+
e('span', { className: 'soul-note-text' }, t('personas.hint')),
|
|
5366
|
+
e(SoulHint, { text: t('personas.hintDetail') })
|
|
5367
|
+
),
|
|
5368
|
+
// 变更记录:一行「改动都能撤销」+ 问号里的全文。
|
|
5369
|
+
//
|
|
5370
|
+
// 这里**刻意不放「打开插件详情」按钮**:宿主 settings.section 槽位的
|
|
5371
|
+
// slotInject 是空串(见 dsh-cordis-client-runner 的槽位清单),设置页
|
|
5372
|
+
// 拿不到任何导航能力,点了也没法跳转 —— 一个点不动的按钮比没有更糟。
|
|
5373
|
+
e('div', { className: 'soul-note soul-note-inline' },
|
|
5374
|
+
e('span', { className: 'soul-note-text' }, t('history.entryHint')),
|
|
5375
|
+
e(SoulHint, { text: t('history.entryHintMore') })
|
|
5376
|
+
)
|
|
2296
5377
|
),
|
|
2297
5378
|
|
|
5379
|
+
// ——「工作区绑定」分组已从设置页撤下(0.8.0 发布前)——
|
|
5380
|
+
//
|
|
5381
|
+
// 原计划 0.8.1 交付「按目录绑人设」。但它要做的事(让目录决定人设)已经被
|
|
5382
|
+
// 0.9.0 的三级隔离(Global → Workspace → Session)整体接管,而这个分组是
|
|
5383
|
+
// 按「目录字符串」思考的 —— 工作区平铺卡片的交互形态与它完全不同,留下它
|
|
5384
|
+
// 只会让用户先学一套即将被替换的操作方式。
|
|
5385
|
+
//
|
|
5386
|
+
// **只摘渲染,逻辑层一行不动**:
|
|
5387
|
+
// · lib/bindings.mjs 的路径规范化 / realpath / 前缀匹配是硬资产,0.9.0 直接复用;
|
|
5388
|
+
// · 宿主侧的 cwd 解析、三条绑定路由、controller 方法全部保留 ——
|
|
5389
|
+
// 没有任何用户持有 soul-bindings.json(特性从未发布),解析恒为 null、
|
|
5390
|
+
// 行为与「没有绑定功能」完全一致,失败方向安全;
|
|
5391
|
+
// · SoulBindings 组件与 .soul-bindings-* 样式同样保留,0.9.0 重写 UI 时替换。
|
|
5392
|
+
//
|
|
5393
|
+
// 对应的行为用例 C29–C33 已随 UI 一并删除(它们测的是点击这个分组之后的
|
|
5394
|
+
// 交互,分组不在渲染树里就没有可断言的对象)。逻辑层仍由 verify-bindings
|
|
5395
|
+
// (47 项)与 verify-e2e-prompt 的 R1–R13 守着。
|
|
5396
|
+
|
|
2298
5397
|
e(SoulAccordion, {
|
|
2299
5398
|
id: 'soul-tools',
|
|
2300
5399
|
title: t('group.tool'),
|
|
@@ -2392,6 +5491,11 @@ window.__ModuleLoader__.load({
|
|
|
2392
5491
|
)
|
|
2393
5492
|
),
|
|
2394
5493
|
|
|
5494
|
+
// —— 变更记录:设置页不再留独立指引行 ——
|
|
5495
|
+
// 指引并入「人设预设」展开区的说明块(与 personas.hint 合并展示):
|
|
5496
|
+
// 独立一行在个性化区块里突兀,而折叠标题旁挂 hover 又说不到点上
|
|
5497
|
+
//(它解释的是展开后的东西)。这里不再渲染任何 history 状态,渲染成本为零。
|
|
5498
|
+
|
|
2395
5499
|
e(SoulAccordion, {
|
|
2396
5500
|
id: 'soul-prompt',
|
|
2397
5501
|
title: t(promptIsDraft ? 'prompt.titleDraft' : 'prompt.title'),
|
|
@@ -2426,6 +5530,14 @@ window.__ModuleLoader__.load({
|
|
|
2426
5530
|
onClick: () => { setPersonaName(''); setShowSavePersona(true) },
|
|
2427
5531
|
disabled: saving
|
|
2428
5532
|
}, t('personas.saveAs')),
|
|
5533
|
+
// 「更新到预设」(0.9.0-s):把当前草稿覆盖写回某个已有自建预设。
|
|
5534
|
+
//
|
|
5535
|
+
// 只列**自建**预设:内置预设不可改(写入路径一律拒绝内置名),
|
|
5536
|
+
// 列出来只会让用户点了吃一个 400。
|
|
5537
|
+
e(ui.Button, {
|
|
5538
|
+
onClick: () => setShowUpdatePersona(true),
|
|
5539
|
+
disabled: saving || editablePersonaNames.length === 0
|
|
5540
|
+
}, t('personas.updateTo')),
|
|
2429
5541
|
e(ui.Button, {
|
|
2430
5542
|
onClick: handleReset,
|
|
2431
5543
|
disabled: saving
|
|
@@ -2468,9 +5580,23 @@ window.__ModuleLoader__.load({
|
|
|
2468
5580
|
e('div', null, t('personas.saveCovers')),
|
|
2469
5581
|
e('div', { className: 'soul-modal-note-excl' }, t('personas.saveExcludes'))
|
|
2470
5582
|
),
|
|
5583
|
+
// 「保存后立即应用」(0.9.0-t):显式让用户选「存完要不要切过去」。
|
|
5584
|
+
//
|
|
5585
|
+
// 不加这个开关时,保存预设后表单里仍是那套未保存编辑 ⇒ 底部同时显示
|
|
5586
|
+
// 「已保存预设「X」」和「有未保存的更改」,读起来矛盾。
|
|
5587
|
+
// 默认不勾:存预设与改当前配置是两件事,不该替用户静默改配置。
|
|
5588
|
+
e('label', { className: 'soul-modal-check' },
|
|
5589
|
+
e('input', {
|
|
5590
|
+
type: 'checkbox',
|
|
5591
|
+
checked: applyAfterSave,
|
|
5592
|
+
onChange: (ev) => setApplyAfterSave(ev.target.checked)
|
|
5593
|
+
}),
|
|
5594
|
+
e('span', null, t('personas.applyAfterSave')),
|
|
5595
|
+
e('span', { className: 'soul-modal-check-hint' }, t('personas.applyAfterSaveHint'))
|
|
5596
|
+
),
|
|
2471
5597
|
e('div', { className: 'soul-modal-actions' },
|
|
2472
5598
|
e(ui.Button, {
|
|
2473
|
-
onClick: () => { setShowSavePersona(false); setPersonaName('') }
|
|
5599
|
+
onClick: () => { setShowSavePersona(false); setPersonaName(''); setApplyAfterSave(false) }
|
|
2474
5600
|
}, t('button.cancel')),
|
|
2475
5601
|
e(ui.Button, {
|
|
2476
5602
|
variant: 'primary',
|
|
@@ -2481,6 +5607,49 @@ window.__ModuleLoader__.load({
|
|
|
2481
5607
|
)
|
|
2482
5608
|
),
|
|
2483
5609
|
|
|
5610
|
+
// ——「更新到预设」弹层(0.9.0-s)——
|
|
5611
|
+
// 列出可覆盖的自建预设,点一个即写入。不询问名字(名字已经定了)。
|
|
5612
|
+
showUpdatePersona && e('div', {
|
|
5613
|
+
className: 'soul-modal-mask',
|
|
5614
|
+
role: 'presentation',
|
|
5615
|
+
onClick: () => setShowUpdatePersona(false)
|
|
5616
|
+
},
|
|
5617
|
+
e('div', {
|
|
5618
|
+
className: 'soul-modal',
|
|
5619
|
+
role: 'dialog',
|
|
5620
|
+
'aria-modal': 'true',
|
|
5621
|
+
'aria-label': t('personas.updateTo'),
|
|
5622
|
+
onClick: (ev) => ev.stopPropagation()
|
|
5623
|
+
},
|
|
5624
|
+
e('div', { className: 'soul-modal-title' }, t('personas.updateTo')),
|
|
5625
|
+
editablePersonaNames.length === 0
|
|
5626
|
+
? e('div', { className: 'soul-modal-note' }, t('personas.updateNone'))
|
|
5627
|
+
: e('div', { className: 'soul-update-list' },
|
|
5628
|
+
...editablePersonaNames.map((name) => e('button', {
|
|
5629
|
+
key: name,
|
|
5630
|
+
type: 'button',
|
|
5631
|
+
className: 'soul-update-item',
|
|
5632
|
+
disabled: saving,
|
|
5633
|
+
onClick: () => { void handleUpdatePersona(name) }
|
|
5634
|
+
},
|
|
5635
|
+
e('span', { className: 'soul-update-name' }, name),
|
|
5636
|
+
// 标出「当前生效」的那个:覆盖它之后 ★ 不会变(字段本来就一样),
|
|
5637
|
+
// 覆盖别的预设会改变那个预设的内容 —— 用户该知道这一点。
|
|
5638
|
+
name === activePersona && e('span', { className: 'soul-update-badge' }, t('personas.active'))
|
|
5639
|
+
))
|
|
5640
|
+
),
|
|
5641
|
+
e('div', { className: 'soul-modal-note' },
|
|
5642
|
+
e('div', null, t('personas.updateCovers')),
|
|
5643
|
+
e('div', { className: 'soul-modal-note-excl' }, t('personas.updateExcludes'))
|
|
5644
|
+
),
|
|
5645
|
+
e('div', { className: 'soul-modal-actions' },
|
|
5646
|
+
e(ui.Button, {
|
|
5647
|
+
onClick: () => setShowUpdatePersona(false)
|
|
5648
|
+
}, t('button.cancel'))
|
|
5649
|
+
)
|
|
5650
|
+
)
|
|
5651
|
+
),
|
|
5652
|
+
|
|
2484
5653
|
dirty && !saving && e('div', { className: 'soul-status soul-dirty' }, t('status.unsaved')),
|
|
2485
5654
|
|
|
2486
5655
|
// —— 内联结果提示(对齐宿主模型设置页的全局形态)——
|
|
@@ -2651,6 +5820,21 @@ window.__ModuleLoader__.load({
|
|
|
2651
5820
|
}
|
|
2652
5821
|
}, 'dsh-soul: global refresh polling')
|
|
2653
5822
|
|
|
5823
|
+
// —— 目录点选服务(0.8.1-d)——
|
|
5824
|
+
//
|
|
5825
|
+
// 用**动态** ctx.inject 拿 uiWorkspace,而不是把它写进上面的静态 inject 数组:
|
|
5826
|
+
// 静态数组里少一个服务,整个插件就不激活(设置页、人设卡、底栏一起消失)。
|
|
5827
|
+
// 目录选择器只是「能点一下就填路径」的便利,缺了它手输照常可用 ⇒ 必须可缺。
|
|
5828
|
+
// 服务到齐时写进模块级 directoryPicker,SoulBindings 的「选择目录…」才可见;
|
|
5829
|
+
// 调用失败(宿主 pickDirectory 抛错)时组件自行降级为手输,见 §7.5。
|
|
5830
|
+
ctx.inject(['uiWorkspace'], (scope) => {
|
|
5831
|
+
directoryPicker = scope.uiWorkspace
|
|
5832
|
+
scope.effect(() => () => {
|
|
5833
|
+
// 服务撤走 = 界面上不该再出现那个按钮(回调可能已被替换成新的 scope)
|
|
5834
|
+
if (directoryPicker === scope.uiWorkspace) directoryPicker = null
|
|
5835
|
+
}, 'dsh-soul: directory picker teardown')
|
|
5836
|
+
})
|
|
5837
|
+
|
|
2654
5838
|
// 暗色适配说明:原生 <select> 的弹出选项列表由浏览器按 color-scheme 渲染。
|
|
2655
5839
|
// 宿主主题呈现器会把 html 的 color-scheme 与 body[data-ds-dark-theme] 随主题
|
|
2656
5840
|
// 投影到 document(见 dsh-client-ui-layout ThemePresenter),因此这里用纯 CSS
|
|
@@ -2813,19 +5997,157 @@ window.__ModuleLoader__.load({
|
|
|
2813
5997
|
ctx.effect(() => registerSettingsNavIcon(), 'dsh-soul: settings navigation icon')
|
|
2814
5998
|
|
|
2815
5999
|
// 注册到输入框工具栏:宿主会把 input.left 放在「操作权限」控件之后。
|
|
6000
|
+
// conversation.input.left 是 scope:'session' 槽位 —— 宿主为它准备的标准 props
|
|
6001
|
+
// 里带 sessionId(ui-session 的 BUILTIN_SOURCE: props: ["sessionId"])。
|
|
6002
|
+
//
|
|
6003
|
+
// 有了它,输入框下方的标签才能显示**当前会话**实际生效的人设与来源;
|
|
6004
|
+
// 否则只能读全局 store 的 activePersona,而那与模型真正拿到的人设可能不同。
|
|
2816
6005
|
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
2817
6006
|
name: 'conversation.input.left',
|
|
2818
6007
|
id: 'soul-quick-toggle',
|
|
2819
6008
|
order: 50,
|
|
2820
6009
|
locale: NS,
|
|
6010
|
+
// ⚠️ inject 回调收到的是**位置参数** [sessionId],不是 props 对象!
|
|
6011
|
+
//
|
|
6012
|
+
// 宿主契约(FactoryInjectParams):
|
|
6013
|
+
// scope 'session' → [sessionId: SessionId]
|
|
6014
|
+
// scope 'session-maybe'→ [sessionId: SessionId | undefined]
|
|
6015
|
+
// scope 'root' → []
|
|
6016
|
+
//
|
|
6017
|
+
// 这里曾写成 inject: (props) => ({ sessionId: props?.sessionId }) ——
|
|
6018
|
+
// props 实际是 sessionId **字符串**,props.sessionId 是 undefined,
|
|
6019
|
+
// 于是 sessionId 恒为 null,标签永远拿不到当前会话,
|
|
6020
|
+
// 层级标注(本会话覆盖 / 全局配置 / 工作区)**整块不显示**。
|
|
6021
|
+
inject: (sessionId) => ({
|
|
6022
|
+
hooks: {
|
|
6023
|
+
soulController: controller.store
|
|
6024
|
+
},
|
|
6025
|
+
controller,
|
|
6026
|
+
sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null
|
|
6027
|
+
})
|
|
6028
|
+
}, SoulQuickToggle))
|
|
6029
|
+
|
|
6030
|
+
|
|
6031
|
+
// 侧栏会话行 hover 面具按钮(0.9.0-ab)。
|
|
6032
|
+
//
|
|
6033
|
+
// conversation.session.header.actions 与 conversation.hero.options 都挂了
|
|
6034
|
+
// 同一人设标识 —— 侧栏是第三处:会话列表里不用点开就能看到每行是谁。
|
|
6035
|
+
//
|
|
6036
|
+
// 槽位:sidebar.workspaces.session.row.action(kind:'list' / scope:'root')。
|
|
6037
|
+
//
|
|
6038
|
+
// ⚠️ sessionId 的来源与 session 槽位**不同**:renderer 的 runInject 对
|
|
6039
|
+
// inject 回调只传 [binding.key, actions] —— root 槽位没有 binding ⇒
|
|
6040
|
+
// inject 无参、拿不到任何东西。而这一槽位的 sessionId 是 **owner props**
|
|
6041
|
+
// (SessionRowOwnerProps: { sessionId, displayTitle }),由 renderer 的
|
|
6042
|
+
// guarded(entry, key, owner = ownerProps) 直接并入组件 props。
|
|
6043
|
+
// ⇒ inject 只负责注入 hooks;sessionId 由组件从 props.sessionId 读。
|
|
6044
|
+
|
|
6045
|
+
// 侧栏面板图标(0.9.0-ac,0.9.0-ad 回滚重做)。
|
|
6046
|
+
//
|
|
6047
|
+
// ❌ 第一版教训:注册 id 'plugins'(复用宿主插件管理器的 cell id)——
|
|
6048
|
+
// 文档里 "reusing a shipped id puts you in THAT cell" 的真正含义是
|
|
6049
|
+
// **替换该单元**,不是"并存"。实测把插件管理器的图标顶没了(用户报:
|
|
6050
|
+
// "你把插件整没了")。
|
|
6051
|
+
//
|
|
6052
|
+
// ✅ 现在:注册**自己的 id**('soul-persona-panel')。点击后宿主
|
|
6053
|
+
// selectPanel('soul-persona-panel') 会切到一个未注册的面板 ——
|
|
6054
|
+
// 宿主文档说这种情况 "preserves the current selection"(保持当前选择,
|
|
6055
|
+
// 不会白屏)。图标本身仍显示:它是 Soul 的入口标识,hover 有名字。
|
|
6056
|
+
//
|
|
6057
|
+
// 长期方案(未做):注册独立 main 面板承载「预设库管理」,那时这个图标
|
|
6058
|
+
// 才有真正的落地页。需要把人设管理从会话语境拆出来 —— 独立的活。
|
|
6059
|
+
// ❌ 暂时隐藏(0.9.0-ag):点击切到未注册面板会"保持当前选择"(点了没反应),
|
|
6060
|
+
// 体验差。等独立 main 面板(预设库管理主页)做好后再启用。
|
|
6061
|
+
// 组件 SoulPanelIcon 与本注册块保留在源码里(注释态),作为那时的起点。
|
|
6062
|
+
// ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
6063
|
+
// name: 'sidebar.panellist',
|
|
6064
|
+
// id: 'soul-persona-panel',
|
|
6065
|
+
// order: 20,
|
|
6066
|
+
// locale: NS,
|
|
6067
|
+
// label: () => navT('nav.label'),
|
|
6068
|
+
// inject: () => ({})
|
|
6069
|
+
// }, SoulPanelIcon))
|
|
6070
|
+
|
|
6071
|
+
// 宿主 hover 信息卡里的「人设」一行(0.9.0-aj)。
|
|
6072
|
+
// 与行尾徽标共用 useSessionRowPersona:同一份数据、同一套降级规则。
|
|
6073
|
+
ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
|
|
6074
|
+
name: 'sidebar.session.row.hover',
|
|
6075
|
+
id: 'soul-persona',
|
|
6076
|
+
order: 50,
|
|
6077
|
+
locale: NS,
|
|
2821
6078
|
inject: () => ({
|
|
2822
6079
|
hooks: {
|
|
2823
6080
|
soulController: controller.store
|
|
2824
6081
|
},
|
|
2825
6082
|
controller
|
|
2826
6083
|
})
|
|
2827
|
-
},
|
|
6084
|
+
}, SoulSessionPersonaHover))
|
|
6085
|
+
|
|
6086
|
+
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
|
|
6087
|
+
name: 'sidebar.workspaces.session.row.action',
|
|
6088
|
+
id: 'soul-persona-badge',
|
|
6089
|
+
order: 300,
|
|
6090
|
+
locale: NS,
|
|
6091
|
+
inject: (owner) => ({
|
|
6092
|
+
hooks: {
|
|
6093
|
+
soulController: controller.store
|
|
6094
|
+
},
|
|
6095
|
+
controller,
|
|
6096
|
+
// 防御性兼容:宿主文档说 owner props 并入**组件** props;万一某版本把它
|
|
6097
|
+
// 交给 inject,这里也把 sessionId 透传过去(组件侧两路都读)。
|
|
6098
|
+
sessionId: owner && typeof owner === 'object' && typeof owner.sessionId === 'string'
|
|
6099
|
+
? owner.sessionId
|
|
6100
|
+
: undefined
|
|
6101
|
+
})
|
|
6102
|
+
}, SoulSessionPersonaBadge))
|
|
6103
|
+
|
|
6104
|
+
// 会话人设标签(第二处):**新会话**那一行(输入框上方)。
|
|
6105
|
+
//
|
|
6106
|
+
// conversation.hero.options —— 宿主文档:"Compact options right-aligned in the
|
|
6107
|
+
// New Session row after the workspace and preset controls; rendered only with a
|
|
6108
|
+
// Session." 正是用户画的红框位置:工作区 chip 与「创造模式」之后、右对齐。
|
|
6109
|
+
//
|
|
6110
|
+
// 与 header 那个(见下)是**同一个组件**(SoulSessionPersonaLabel)、读同一份
|
|
6111
|
+
// store 数据 —— 新会话时 hero 区显示这个,首轮对话后 hero 消失、header 出现,
|
|
6112
|
+
// 宿主本来就做了这个交接。
|
|
6113
|
+
ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({
|
|
6114
|
+
name: 'conversation.hero.options',
|
|
6115
|
+
id: 'soul-persona-label-hero',
|
|
6116
|
+
order: 10,
|
|
6117
|
+
locale: NS,
|
|
6118
|
+
// scope:'session' 槽位 ⇒ inject 收**位置参数** [sessionId](宿主契约
|
|
6119
|
+
// FactoryInjectParams),不是 props 对象。
|
|
6120
|
+
inject: (sessionId) => ({
|
|
6121
|
+
hooks: {
|
|
6122
|
+
soulController: controller.store
|
|
6123
|
+
},
|
|
6124
|
+
controller,
|
|
6125
|
+
sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null
|
|
6126
|
+
})
|
|
6127
|
+
}, SoulSessionPersonaLabel))
|
|
2828
6128
|
|
|
6129
|
+
// 会话人设标签:挂到会话 header(标题旁,「创造模式」「费用明细」那一行)。
|
|
6130
|
+
//
|
|
6131
|
+
// 槽位是 kind:'list' / scope:'session',可安全追加(宿主的 occupants:
|
|
6132
|
+
// agent-team(-20) / agent-preset(-10) / subagent-catalog(10) / job-list(20))。
|
|
6133
|
+
// order: 30 排在它们之后 —— 标签在视觉上属于「补充信息」,
|
|
6134
|
+
// 不该插到「创造模式」和「费用明细」这些主动作之间。
|
|
6135
|
+
//
|
|
6136
|
+
// inject 收到的是**位置参数** [sessionId](宿主契约 FactoryInjectParams:
|
|
6137
|
+
// scope 'session' → [sessionId: SessionId]),不是 props 对象。
|
|
6138
|
+
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
6139
|
+
name: 'conversation.session.header.actions',
|
|
6140
|
+
id: 'soul-persona-label',
|
|
6141
|
+
order: 30,
|
|
6142
|
+
locale: NS,
|
|
6143
|
+
inject: (sessionId) => ({
|
|
6144
|
+
hooks: {
|
|
6145
|
+
soulController: controller.store
|
|
6146
|
+
},
|
|
6147
|
+
controller,
|
|
6148
|
+
sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null
|
|
6149
|
+
})
|
|
6150
|
+
}, SoulSessionPersonaLabel))
|
|
2829
6151
|
|
|
2830
6152
|
// 输入框光轨:注册到输入框卡片内部的 overlay 槽位。
|
|
2831
6153
|
// 组件不渲染可见内容——它按会话运行态在最近的输入框卡片上挂载/隐藏 SVG 环;
|
|
@@ -2856,6 +6178,82 @@ window.__ModuleLoader__.load({
|
|
|
2856
6178
|
})
|
|
2857
6179
|
}, SoulSettings))
|
|
2858
6180
|
|
|
6181
|
+
// —— 变更记录主入口:插件详情页(0.8.1 双入口)——
|
|
6182
|
+
// 槽位 plugins.detail.section(list/root):宿主在插件详情页自身内容下方
|
|
6183
|
+
// 为每个注册者渲染一个自定义区块;subject 形态与判据见 SoulHistorySection
|
|
6184
|
+
// 头注释(只服务 subject.pkg.name === 'dsh-soul' 的 bundle 详情页)。
|
|
6185
|
+
// 槽位不存在时 inject 静默不注册 ⇒ 由 verify-config 的源文本契约断言钉住槽位名。
|
|
6186
|
+
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
|
6187
|
+
name: 'plugins.detail.section',
|
|
6188
|
+
id: 'soul-detail-history',
|
|
6189
|
+
order: 50,
|
|
6190
|
+
locale: NS,
|
|
6191
|
+
inject: () => ({
|
|
6192
|
+
hooks: {
|
|
6193
|
+
soulController: controller.store
|
|
6194
|
+
},
|
|
6195
|
+
controller
|
|
6196
|
+
})
|
|
6197
|
+
}, SoulHistorySection))
|
|
6198
|
+
|
|
6199
|
+
// —— 会话「人设」tab(0.9.0-d)——
|
|
6200
|
+
//
|
|
6201
|
+
// 槽位 `conversation.view` 是**列表**槽位:宿主用 `slots.entries(...)` 动态枚举
|
|
6202
|
+
// 生成 tab 栏(与轨迹 / 上下文 / 编队同机制),所以注册后会自动出现,不需要改宿主。
|
|
6203
|
+
//
|
|
6204
|
+
// 唯一的入口价值在于它的 inject 签名是 `(sessionId) => props` —— 这是插件
|
|
6205
|
+
// 能拿到**当前会话 id** 的唯一正规途径(设置页的 slotInject 是空串,拿不到)。
|
|
6206
|
+
// 因此 sessionId 必须原样传给组件,不能只传 controller。
|
|
6207
|
+
//
|
|
6208
|
+
// order 排在宿主内置视图之后(对话 0 / 轨迹 10 / 上下文 20 / 编队 30 级),
|
|
6209
|
+
// 用 90 确保「人设」在最右,不打乱用户已经熟悉的顺序。
|
|
6210
|
+
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
6211
|
+
name: 'conversation.view',
|
|
6212
|
+
id: 'soul-persona',
|
|
6213
|
+
order: 90,
|
|
6214
|
+
label: () => navT('session.tabLabel'),
|
|
6215
|
+
locale: NS,
|
|
6216
|
+
inject: (sessionId) => ({
|
|
6217
|
+
hooks: {
|
|
6218
|
+
soulController: controller.store
|
|
6219
|
+
},
|
|
6220
|
+
controller,
|
|
6221
|
+
sessionId: typeof sessionId === 'string' ? sessionId : null
|
|
6222
|
+
})
|
|
6223
|
+
}, SoulSessionPersona))
|
|
6224
|
+
|
|
6225
|
+
// —— 工作区人设(0.9.0-e)——
|
|
6226
|
+
//
|
|
6227
|
+
// 注册到插件详情页:与变更记录同一槽位,宿主为每个注册者渲染一个区块。
|
|
6228
|
+
// 不与设置页放在一起的原因写在 SoulWorkspaces 头注释里(低频 + 需要空间)。
|
|
6229
|
+
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
|
6230
|
+
name: 'plugins.detail.section',
|
|
6231
|
+
id: 'soul-detail-workspaces',
|
|
6232
|
+
order: 40, // 排在变更记录(50)之前:人设是"怎么配",记录是"改过什么"
|
|
6233
|
+
locale: NS,
|
|
6234
|
+
inject: () => ({
|
|
6235
|
+
hooks: {
|
|
6236
|
+
soulController: controller.store
|
|
6237
|
+
},
|
|
6238
|
+
controller
|
|
6239
|
+
})
|
|
6240
|
+
}, SoulWorkspaces))
|
|
6241
|
+
|
|
6242
|
+
// —— 会话覆盖清理(0.9.0-f)——
|
|
6243
|
+
// 同一槽位、order 60:排在变更记录之后(它是这三块里最低频的运维动作)。
|
|
6244
|
+
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
|
6245
|
+
name: 'plugins.detail.section',
|
|
6246
|
+
id: 'soul-detail-sessions',
|
|
6247
|
+
order: 60,
|
|
6248
|
+
locale: NS,
|
|
6249
|
+
inject: () => ({
|
|
6250
|
+
hooks: {
|
|
6251
|
+
soulController: controller.store
|
|
6252
|
+
},
|
|
6253
|
+
controller
|
|
6254
|
+
})
|
|
6255
|
+
}, SoulSessions))
|
|
6256
|
+
|
|
2859
6257
|
// 初始配置的拉取已并入上面的全局轮询 —— 它的 refresh() 在注册时立即执行一次
|
|
2860
6258
|
// (含 loadConfig + loadPersonas),这里再调一次就是同一次装配发两个相同请求。
|
|
2861
6259
|
}
|