dsh-soul 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/index.mjs CHANGED
@@ -2,12 +2,19 @@
2
2
  //
3
3
  // 在设置页面添加「个性化」栏目:
4
4
  // - 启用/禁用个性化设置
5
- // - 选择回复风格和语调
6
- // - 输入自定义指令
5
+ // - 「关于你」(昵称/职业/介绍)、回复风格和语调、特质、输出语言、自定义指令
6
+ // - 人设预设:保存当前为预设、一键使用(★ 标记当前匹配项)、删除
7
+ // - Agent 工具:set_persona 确认模式开关
8
+ // - dirty 检测(无改动禁用保存)、统一 toast 提示
9
+ // - 查看当前生效提示词(只读)与字符数
7
10
  //
8
11
  // 纯 JS + React jsx-runtime 手写(不依赖 JSX 构建),开箱即用。
9
12
  // 注意:dsh web 使用 React 17+ 的 jsx-runtime,children 必须放在 props.children
10
13
  // 里;不能把 children 当第三个参数传(第三个参数是 key)。
14
+ //
15
+ // 文案全部走宿主 locale 词典:槽位声明 locale: NS 后,渲染器会把绑定本命名
16
+ // 空间的 t 注入组件(props.t),并在语言切换时重渲染;导航 label 由 apply 内
17
+ // 绑定的 navT 实时解析,壳层在语言切换时会重新解析 label。
11
18
 
12
19
  window.__ModuleLoader__.load({
13
20
  id: 'dsh-soul',
@@ -39,19 +46,23 @@ window.__ModuleLoader__.load({
39
46
  const INITIAL = {
40
47
  enabled: true,
41
48
  nickname: '',
42
- // 关于你:用户职业 / 用户介绍
43
49
  occupation: '',
44
50
  bio: '',
45
- // 回复风格和语调(合并为单一选项)
46
51
  style: 'professional',
47
- // 特质:标题和列表 / 表情符号
48
52
  headingLists: 'default',
49
53
  emoji: 'default',
50
54
  language: 'zh',
51
55
  customInstructions: '',
56
+ // set_persona 确认模式(v0.5.0)
57
+ requireToolConfirmation: false,
58
+ // 人设预设库与当前匹配项(GET /api/soul/personas 带回)
59
+ personas: null,
60
+ activePersona: null,
52
61
  loading: false,
53
62
  saving: false,
54
- error: null
63
+ error: null,
64
+ // 最近一次保存实际变化的字段名数组(来自写路径的 changed)
65
+ lastChanged: null
55
66
  }
56
67
 
57
68
  // 第三方插件不能 require('@deepseek-ai/dsh-client-runtime/client')——
@@ -90,29 +101,28 @@ window.__ModuleLoader__.load({
90
101
  this.disposed = false
91
102
  }
92
103
 
104
+ // 把服务端返回的完整配置映射进 store
105
+ applyConfig(s, config) {
106
+ s.enabled = config.enabled
107
+ s.nickname = config.nickname || ''
108
+ s.occupation = config.occupation || ''
109
+ s.bio = config.bio || ''
110
+ s.style = config.style
111
+ s.headingLists = config.headingLists || 'default'
112
+ s.emoji = config.emoji || 'default'
113
+ s.language = config.language || 'zh'
114
+ s.customInstructions = config.customInstructions
115
+ s.requireToolConfirmation = config.requireToolConfirmation === true
116
+ }
117
+
93
118
  async loadConfig() {
94
119
  if (this.disposed) return
95
120
  this.store.update(s => { s.loading = true; s.error = null })
96
-
121
+
97
122
  try {
98
- const url = new URL('/api/soul/config', hostBase())
99
- const response = await this.fetcher(url)
100
- const payload = await response.json().catch(() => ({}))
101
-
102
- if (!response.ok || payload.ok !== true) {
103
- throw new Error(payload.error || `HTTP ${response.status}`)
104
- }
105
-
123
+ const payload = await this.postJSON('/api/soul/config', null, { method: 'GET' })
106
124
  this.store.update(s => {
107
- s.enabled = payload.config.enabled
108
- s.nickname = payload.config.nickname || ''
109
- s.occupation = payload.config.occupation || ''
110
- s.bio = payload.config.bio || ''
111
- s.style = payload.config.style
112
- s.headingLists = payload.config.headingLists || 'default'
113
- s.emoji = payload.config.emoji || 'default'
114
- s.language = payload.config.language || 'zh'
115
- s.customInstructions = payload.config.customInstructions
125
+ this.applyConfig(s, payload.config)
116
126
  s.loading = false
117
127
  })
118
128
  } catch (error) {
@@ -127,32 +137,15 @@ window.__ModuleLoader__.load({
127
137
  async saveConfig(config) {
128
138
  if (this.disposed) return
129
139
  this.store.update(s => { s.saving = true; s.error = null })
130
-
140
+
131
141
  try {
132
- const url = new URL('/api/soul/config', hostBase())
133
- const response = await this.fetcher(url, {
134
- method: 'POST',
135
- headers: { 'content-type': 'application/json' },
136
- body: JSON.stringify(config)
137
- })
138
- const payload = await response.json().catch(() => ({}))
139
-
140
- if (!response.ok || payload.ok !== true) {
141
- throw new Error(payload.error || `HTTP ${response.status}`)
142
- }
143
-
142
+ const payload = await this.postJSON('/api/soul/config', config)
144
143
  this.store.update(s => {
145
- s.enabled = payload.config.enabled
146
- s.nickname = payload.config.nickname || ''
147
- s.occupation = payload.config.occupation || ''
148
- s.bio = payload.config.bio || ''
149
- s.style = payload.config.style
150
- s.headingLists = payload.config.headingLists || 'default'
151
- s.emoji = payload.config.emoji || 'default'
152
- s.language = payload.config.language || 'zh'
153
- s.customInstructions = payload.config.customInstructions
144
+ this.applyConfig(s, payload.config)
145
+ s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
154
146
  s.saving = false
155
147
  })
148
+ return payload
156
149
  } catch (error) {
157
150
  if (this.disposed) return
158
151
  this.store.update(s => {
@@ -165,28 +158,15 @@ window.__ModuleLoader__.load({
165
158
  async resetConfig() {
166
159
  if (this.disposed) return
167
160
  this.store.update(s => { s.saving = true; s.error = null })
168
-
161
+
169
162
  try {
170
- const url = new URL('/api/soul/config/reset', hostBase())
171
- const response = await this.fetcher(url, { method: 'POST' })
172
- const payload = await response.json().catch(() => ({}))
173
-
174
- if (!response.ok || payload.ok !== true) {
175
- throw new Error(payload.error || `HTTP ${response.status}`)
176
- }
177
-
163
+ const payload = await this.postJSON('/api/soul/config/reset')
178
164
  this.store.update(s => {
179
- s.enabled = payload.config.enabled
180
- s.nickname = payload.config.nickname || ''
181
- s.occupation = payload.config.occupation || ''
182
- s.bio = payload.config.bio || ''
183
- s.style = payload.config.style
184
- s.headingLists = payload.config.headingLists || 'default'
185
- s.emoji = payload.config.emoji || 'default'
186
- s.language = payload.config.language || 'zh'
187
- s.customInstructions = payload.config.customInstructions
165
+ this.applyConfig(s, payload.config)
166
+ s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
188
167
  s.saving = false
189
168
  })
169
+ return payload
190
170
  } catch (error) {
191
171
  if (this.disposed) return
192
172
  this.store.update(s => {
@@ -196,6 +176,52 @@ window.__ModuleLoader__.load({
196
176
  }
197
177
  }
198
178
 
179
+ // 人设预设:列表 / 保存 / 使用 / 删除
180
+ async fetchPersonas() {
181
+ return await this.postJSON('/api/soul/personas', null, { method: 'GET' })
182
+ }
183
+
184
+ async savePersona(name) {
185
+ return await this.postJSON('/api/soul/personas/save', { name })
186
+ }
187
+
188
+ async usePersona(name) {
189
+ const payload = await this.postJSON('/api/soul/personas/use', { name })
190
+ if (!this.disposed) {
191
+ this.store.update(s => {
192
+ this.applyConfig(s, payload.config)
193
+ s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
194
+ })
195
+ }
196
+ return payload
197
+ }
198
+
199
+ async deletePersona(name) {
200
+ return await this.postJSON('/api/soul/personas/delete', { name })
201
+ }
202
+
203
+ // 统一请求:body 为 null 时不携带请求体(GET 用)
204
+ async postJSON(path, body, overrides = {}) {
205
+ const url = new URL(path, hostBase())
206
+ const init = { method: overrides.method || 'POST', ...overrides }
207
+ if (body !== null && body !== undefined) {
208
+ init.headers = { 'content-type': 'application/json' }
209
+ init.body = JSON.stringify(body)
210
+ }
211
+ const response = await this.fetcher(url, init)
212
+ const payload = await response.json().catch(() => ({}))
213
+ if (!response.ok || payload.ok !== true) {
214
+ throw new Error(payload.error || `HTTP ${response.status}`)
215
+ }
216
+ return payload
217
+ }
218
+
219
+ // 读取当前生效(已保存)的提示词:{ ok, prompt, enabled }
220
+ async fetchPrompt() {
221
+ const payload = await this.postJSON('/api/soul/prompt', null, { method: 'GET' })
222
+ return payload
223
+ }
224
+
199
225
  async dispose() {
200
226
  this.disposed = true
201
227
  }
@@ -206,29 +232,47 @@ window.__ModuleLoader__.load({
206
232
  // -------------------------------------------------------------------------
207
233
 
208
234
  const css = [
209
- '.soul-section{margin:24px 0;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-l1)}',
235
+ '.soul-section{margin:24px 0;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}',
210
236
  '.soul-section h3{margin:0 0 16px 0;font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
211
237
  '.soul-field{margin-bottom:16px;width:100%}',
212
238
  '.soul-field label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
213
- '.soul-field select,.soul-field textarea,.soul-field input[type=text]{width:100%;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-l1);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;box-sizing:border-box}',
239
+ '.soul-field select,.soul-field textarea,.soul-field input[type=text]{width:100%;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;box-sizing:border-box}',
240
+ '.soul-field select option{color:var(--dsw-alias-label-primary)}',
241
+ '.soul-section{color-scheme:light}',
242
+ 'body[data-ds-dark-theme] .soul-section{color-scheme:dark}',
243
+ '.soul-field select{color-scheme:light}',
244
+ 'body[data-ds-dark-theme] .soul-field select{color-scheme:dark}',
214
245
  '.soul-field textarea{min-height:100px;resize:vertical}',
215
246
  '.soul-field textarea.soul-textarea-sm{min-height:60px}',
216
247
  '.soul-group-title{margin:20px 0 12px 0;padding-bottom:8px;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
217
- '.soul-field select:focus,.soul-field textarea:focus,.soul-field input[type=text]:focus{outline:none;border-color:var(--dsw-alias-border-focus);box-shadow:0 0 0 2px var(--dsw-alias-border-focus-alpha)}',
248
+ '.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)}',
218
249
  '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:16px}',
219
250
  '.soul-toggle label{margin:0;font-size:13px;color:var(--dsw-alias-label-primary)}',
220
251
  '.soul-buttons{display:flex;gap:8px;margin-top:16px}',
221
- '.soul-error{margin-top:8px;padding:8px 12px;background:var(--dsw-alias-bg-danger);border:1px solid var(--dsw-alias-border-danger);border-radius:6px;color:var(--dsw-alias-label-danger);font-size:12px;width:100%;box-sizing:border-box}',
252
+ '.soul-error{margin-top:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-label-error);border-radius:6px;color:var(--dsw-alias-label-error);font-size:12px;width:100%;box-sizing:border-box}',
222
253
  '.soul-hint{position:relative;display:inline-flex;align-items:center;margin-left:6px;color:var(--dsw-alias-label-secondary);cursor:help;vertical-align:middle}',
223
254
  '.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
224
255
  '.soul-hint-tip{position:absolute;bottom:calc(100% + 8px);left:0;padding:8px 10px;background:#ffffff;border:1px solid rgba(0,0,0,0.1);border-radius:6px;font-size:12px;font-weight:400;line-height:1.5;color:rgba(0,0,0,0.85);white-space:normal;width:max-content;max-width:260px;text-align:left;opacity:0;visibility:hidden;transition:opacity .15s ease;pointer-events:none;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.15)}',
225
256
  '.soul-hint:hover .soul-hint-tip{opacity:1;visibility:visible}',
226
257
  '.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
258
+ '.soul-dirty{color:#d46b08}',
259
+ '.soul-persona-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px dashed var(--dsw-alias-border-l2);font-size:12px}',
260
+ '.soul-persona-name{color:var(--dsw-alias-label-primary);font-weight:500;white-space:nowrap}',
261
+ '.soul-persona-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
262
+ '.soul-persona-actions{display:flex;gap:10px;white-space:nowrap}',
263
+ '.soul-persona-save{display:flex;gap:8px}',
264
+ '.soul-persona-save input[type=text]{flex:1}',
265
+ '.soul-prompt-toggle{margin-top:8px}',
266
+ '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
267
+ '.soul-prompt-link:hover{color:var(--dsw-alias-label-primary)}',
268
+ '.soul-persona-danger{color:var(--dsw-alias-label-error)}',
269
+ '.soul-prompt-pre{margin:8px 0 0 0;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto;font-family:Consolas,Menlo,monospace;text-align:left;width:100%;box-sizing:border-box}',
227
270
  '.soul-toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;z-index:10000;box-shadow:0 2px 8px rgba(0,0,0,0.15);pointer-events:none;text-align:center;min-width:auto;white-space:nowrap}',
228
271
  '.soul-toast-success{background:#f6ffed;border:1px solid #b7eb8f;color:#52c41a}',
272
+ '.soul-toast-error{background:#fff1f0;border:1px solid #ffa39e;color:#f5222d}',
229
273
  '@keyframes fadeInOut{0%{opacity:0;transform:translate(-50%,-50%) scale(0.9)}15%{opacity:1;transform:translate(-50%,-50%) scale(1)}85%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(0.9)}}'
230
274
  ].join('')
231
-
275
+
232
276
  const tagId = 'dsh-soul/styles.css'
233
277
  let styleTagMissing = false
234
278
  if (typeof document !== 'undefined') {
@@ -247,38 +291,168 @@ window.__ModuleLoader__.load({
247
291
  }
248
292
 
249
293
  // -------------------------------------------------------------------------
250
- // 组件
294
+ // 文案词典(键集中英完全对齐;渲染器按槽位 locale 注入 t)
251
295
  // -------------------------------------------------------------------------
252
296
 
253
- // 回复风格和语调(合并为单一选项)
254
- const STYLE_TONE_OPTIONS = [
255
- { value: 'professional', label: '专业严谨' },
256
- { value: 'casual', label: '轻松自然' },
257
- { value: 'humorous', label: '幽默风趣' },
258
- { value: 'roast', label: '吐槽达人' },
259
- { value: 'efficient', label: '高效干练' }
260
- ]
297
+ const zh = {
298
+ 'nav.label': '个性化',
299
+ 'settings.title': '个性化设置',
300
+ 'toggle.enabled': '启用个性化设置',
301
+ 'group.aboutYou': '关于你',
302
+ 'field.nickname': '用户昵称',
303
+ 'field.nicknamePlaceholder': '输入你的昵称,回复时会称呼你',
304
+ 'field.occupation': '用户职业',
305
+ 'field.occupationPlaceholder': '例如:软件工程师、学生、产品经理',
306
+ 'field.bio': '用户介绍',
307
+ 'field.bioPlaceholder': '简单介绍自己,让回复更贴合你的背景',
308
+ 'group.traits': '特质',
309
+ 'field.style': '回复风格和语调',
310
+ 'hint.style': '设置 Agent 回复你的风格和语调。这不会影响 Agent 的功能。',
311
+ 'field.headingLists': '标题和列表',
312
+ 'hint.headingLists': '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中标题和列表的使用程度。',
313
+ 'field.emoji': '表情符号',
314
+ 'hint.emoji': '在回复风格和语调的基础上选择额外的自定义特质项。控制表情符号的使用程度。',
315
+ 'field.language': '输出语言',
316
+ 'hint.language': '设置 Agent 回复你使用的语言,同时影响 /soul 命令的输出语言。',
317
+ 'field.instructions': '自定义指令',
318
+ 'hint.instructions': '补充角色、习惯等个性化要求。建议不要重复设置回复风格和语调类似的话术,以免与上方选项冲突。',
319
+ 'field.instructionsPlaceholder': '输入自定义指令,可以是其他行为、回复风格和语调等偏好设置',
320
+ 'group.personas': '人设预设',
321
+ 'personas.save': '保存当前为预设',
322
+ 'personas.savePlaceholder': '预设名称(1-30 字符)',
323
+ 'personas.use': '使用',
324
+ 'personas.delete': '删除',
325
+ 'personas.confirmDelete': '确定删除预设「{name}」?',
326
+ 'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
327
+ 'style.professional': '专业严谨',
328
+ 'style.casual': '轻松自然',
329
+ 'style.humorous': '幽默风趣',
330
+ 'style.roast': '吐槽达人',
331
+ 'style.efficient': '高效干练',
332
+ 'trait.headingLists.default': '默认',
333
+ 'trait.headingLists.more': '增强(采用清晰格式和列表结构)',
334
+ 'trait.headingLists.less': '减弱(使用更多段落文本)',
335
+ 'trait.emoji.default': '默认',
336
+ 'trait.emoji.more': '增强(使用较多表情符号)',
337
+ 'trait.emoji.less': '减弱(尽量减少使用表情符号)',
338
+ 'group.tool': 'Agent 工具',
339
+ 'field.toolConfirm': '人设变更需确认',
340
+ 'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
341
+ 'button.save': '保存设置',
342
+ 'button.saving': '保存中...',
343
+ 'button.reset': '重置默认',
344
+ 'status.loading': '加载中...',
345
+ 'status.unsaved': '有未保存的更改',
346
+ 'toast.saved': '✅ 设置已保存',
347
+ 'toast.noChanges': '✅ 配置无变化',
348
+ 'toast.reset': '✅ 已重置为默认值',
349
+ 'toast.personaSaved': '✅ 预设已保存',
350
+ 'toast.personaUsed': '✅ 预设已应用',
351
+ 'toast.personaUnchanged': 'ℹ 预设与当前配置一致',
352
+ 'toast.personaDeleted': '✅ 预设已删除',
353
+ 'prompt.view': '查看当前生效提示词',
354
+ 'prompt.hide': '收起提示词',
355
+ 'prompt.loading': '提示词加载中...',
356
+ 'prompt.empty': '(个性化已禁用,提示词为空)',
357
+ 'prompt.chars': '当前字符数:{n}'
358
+ }
261
359
 
262
- // 特质:标题和列表
263
- const HEADING_LISTS_OPTIONS = [
264
- { value: 'default', label: '默认' },
265
- { value: 'more', label: '增强(采用清晰格式和列表结构)' },
266
- { value: 'less', label: '减弱(使用更多段落文本)' }
267
- ]
360
+ const en = {
361
+ 'nav.label': 'Personalization',
362
+ 'settings.title': 'Personalization Settings',
363
+ 'toggle.enabled': 'Enable personalization',
364
+ 'group.aboutYou': 'About you',
365
+ 'field.nickname': 'Nickname',
366
+ 'field.nicknamePlaceholder': 'Your nickname — the agent will address you by it',
367
+ 'field.occupation': 'Occupation',
368
+ 'field.occupationPlaceholder': 'e.g. software engineer, student, product manager',
369
+ 'field.bio': 'Bio',
370
+ 'field.bioPlaceholder': 'A short introduction so replies fit your background',
371
+ 'group.traits': 'Traits',
372
+ 'field.style': 'Reply style & tone',
373
+ 'hint.style': "Sets the style and tone the agent uses when replying to you. It does not affect the agent's capabilities.",
374
+ 'field.headingLists': 'Headings & lists',
375
+ 'hint.headingLists': 'An extra trait layered on top of style & tone. Controls how much answers rely on headings and lists.',
376
+ 'field.emoji': 'Emoji',
377
+ 'hint.emoji': 'An extra trait layered on top of style & tone. Controls how many emojis answers use.',
378
+ 'field.language': 'Output language',
379
+ 'hint.language': 'Sets the language the agent replies in; also affects the /soul command output.',
380
+ 'field.instructions': 'Custom instructions',
381
+ 'hint.instructions': 'Extra persona or habit requirements. Avoid repeating wording similar to the style & tone option above to prevent conflicts.',
382
+ 'field.instructionsPlaceholder': 'Custom instructions — other behaviors, style or tone preferences',
383
+ 'group.personas': 'Personas',
384
+ 'personas.save': 'Save current as persona',
385
+ 'personas.savePlaceholder': 'Persona name (1-30 chars)',
386
+ 'personas.use': 'Use',
387
+ 'personas.delete': 'Delete',
388
+ 'personas.confirmDelete': 'Delete persona "{name}"?',
389
+ 'personas.empty': 'No personas yet — save the current config to switch with one click',
390
+ 'style.professional': 'Professional',
391
+ 'style.casual': 'Casual',
392
+ 'style.humorous': 'Humorous',
393
+ 'style.roast': 'Roast Master',
394
+ 'style.efficient': 'Efficient',
395
+ 'trait.headingLists.default': 'Default',
396
+ 'trait.headingLists.more': 'More (clear formatting with headings and lists)',
397
+ 'trait.headingLists.less': 'Less (more paragraph text)',
398
+ 'trait.emoji.default': 'Default',
399
+ 'trait.emoji.more': 'More (frequent emoji usage)',
400
+ 'trait.emoji.less': 'Less (minimal emoji usage)',
401
+ 'group.tool': 'Agent tools',
402
+ 'field.toolConfirm': 'Require confirmation for persona changes',
403
+ 'hint.toolConfirm': 'When enabled, changes made by the agent through the set_persona tool do not apply immediately — confirm with /soul confirm or reject with /soul reject in the conversation.',
404
+ 'button.save': 'Save Settings',
405
+ 'button.saving': 'Saving...',
406
+ 'button.reset': 'Reset to Default',
407
+ 'status.loading': 'Loading...',
408
+ 'status.unsaved': 'You have unsaved changes',
409
+ 'toast.saved': '✅ Settings saved',
410
+ 'toast.noChanges': '✅ No changes to save',
411
+ 'toast.reset': '✅ Reset to defaults',
412
+ 'toast.personaSaved': '✅ Persona saved',
413
+ 'toast.personaUsed': '✅ Persona applied',
414
+ 'toast.personaUnchanged': 'ℹ Persona matches current config',
415
+ 'toast.personaDeleted': '✅ Persona deleted',
416
+ 'prompt.view': 'View the active system prompt',
417
+ 'prompt.hide': 'Hide the prompt',
418
+ 'prompt.loading': 'Loading prompt...',
419
+ 'prompt.empty': '(personalization disabled — the prompt is empty)',
420
+ 'prompt.chars': 'Current character count: {n}'
421
+ }
268
422
 
269
- // 特质:表情符号
270
- const EMOJI_OPTIONS = [
271
- { value: 'default', label: '默认' },
272
- { value: 'more', label: '增强(使用较多表情符号)' },
273
- { value: 'less', label: '减弱(尽量减少使用表情符号)' }
274
- ]
423
+ // 图标替换匹配的导航文案(词典中 nav.label 的全部语言取值)
424
+ const NAV_LABELS = new Set([zh['nav.label'], en['nav.label']])
425
+
426
+ // 组件内翻译回退:渲染器未注入 t 时使用中文词典(保证组件可独立渲染/测试)
427
+ function makeFallbackT(dict) {
428
+ return (key, params) => {
429
+ let text = dict[key] ?? key
430
+ if (params) {
431
+ for (const [name, value] of Object.entries(params)) {
432
+ text = text.split(`{${name}}`).join(String(value))
433
+ }
434
+ }
435
+ return text
436
+ }
437
+ }
438
+ const FALLBACK_T = makeFallbackT(zh)
275
439
 
276
- // 选项用原生语言显示,两种 UI 语言下均自解释
440
+ // -------------------------------------------------------------------------
441
+ // 组件
442
+ // -------------------------------------------------------------------------
443
+
444
+ // 选项取值(label 走词典;取值与 lib/config.mjs 的合法值保持一致)
445
+ const STYLE_VALUES = ['professional', 'casual', 'humorous', 'roast', 'efficient']
446
+ const TRAIT_VALUES = ['default', 'more', 'less']
447
+ // 输出语言选项(两种 UI 语言下均自解释,不进词典)
277
448
  const LANGUAGE_OPTIONS = [
278
449
  { value: 'zh', label: '中文' },
279
450
  { value: 'en', label: 'English' }
280
451
  ]
281
452
 
453
+ // 表单字段与 store 字段的一一对应(dirty 检测与保存载荷共用)
454
+ const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions', 'requireToolConfirmation']
455
+
282
456
  // 提示词小图标:hover 展示说明文字
283
457
  function SoulHint(props) {
284
458
  return e('span', { className: 'soul-hint', 'aria-label': props.text },
@@ -303,9 +477,11 @@ window.__ModuleLoader__.load({
303
477
 
304
478
  function SoulSettings(props) {
305
479
  const { useSoulController, controller } = props
480
+ // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
481
+ const t = typeof props.t === 'function' ? props.t : FALLBACK_T
306
482
  const state = useSoulController((state) => state)
307
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, loading, saving, error } = state
308
-
483
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, personas, activePersona, loading, saving, error } = state
484
+
309
485
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
310
486
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
311
487
  const [localOccupation, setLocalOccupation] = React.useState(occupation || '')
@@ -315,9 +491,14 @@ window.__ModuleLoader__.load({
315
491
  const [localEmoji, setLocalEmoji] = React.useState(emoji)
316
492
  const [localLanguage, setLocalLanguage] = React.useState(language || 'zh')
317
493
  const [localInstructions, setLocalInstructions] = React.useState(customInstructions)
318
- const [showSuccess, setShowSuccess] = React.useState(false)
319
- const [showResetSuccess, setShowResetSuccess] = React.useState(false)
320
-
494
+ const [localToolConfirm, setLocalToolConfirm] = React.useState(requireToolConfirmation === true)
495
+ const [personaName, setPersonaName] = React.useState('')
496
+ const [toast, setToast] = React.useState(null)
497
+ const [showPrompt, setShowPrompt] = React.useState(false)
498
+ const [promptText, setPromptText] = React.useState('')
499
+ const [promptLoading, setPromptLoading] = React.useState(false)
500
+ const [promptError, setPromptError] = React.useState(null)
501
+
321
502
  // 同步状态
322
503
  React.useEffect(() => {
323
504
  setLocalEnabled(enabled)
@@ -329,48 +510,118 @@ window.__ModuleLoader__.load({
329
510
  setLocalEmoji(emoji)
330
511
  setLocalLanguage(language || 'zh')
331
512
  setLocalInstructions(customInstructions)
332
- }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions])
333
-
334
- // 保存成功后显示提示,2秒后自动消失
513
+ setLocalToolConfirm(requireToolConfirmation === true)
514
+ }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation])
515
+
516
+ // toast 自动消失
335
517
  React.useEffect(() => {
336
- if (showSuccess) {
337
- const timer = setTimeout(() => setShowSuccess(false), 2000)
518
+ if (toast) {
519
+ const timer = setTimeout(() => setToast(null), 2000)
338
520
  return () => clearTimeout(timer)
339
521
  }
340
- }, [showSuccess])
341
-
342
- // 重置成功后显示提示,2秒后自动消失
343
- React.useEffect(() => {
344
- if (showResetSuccess) {
345
- const timer = setTimeout(() => setShowResetSuccess(false), 2000)
346
- return () => clearTimeout(timer)
522
+ }, [toast])
523
+
524
+ // dirty 检测:本地表单与已保存配置逐字段比较
525
+ const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation }
526
+ const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm }
527
+ const dirty = FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
528
+
529
+ const loadPrompt = async () => {
530
+ setPromptLoading(true)
531
+ setPromptError(null)
532
+ try {
533
+ const payload = await controller.fetchPrompt()
534
+ setPromptText(payload.prompt || '')
535
+ } catch (err) {
536
+ setPromptError(messageOf(err))
537
+ } finally {
538
+ setPromptLoading(false)
539
+ }
540
+ }
541
+
542
+ const togglePrompt = async () => {
543
+ const next = !showPrompt
544
+ setShowPrompt(next)
545
+ if (next) await loadPrompt()
546
+ }
547
+
548
+ const loadPersonas = async () => {
549
+ try {
550
+ const payload = await controller.fetchPersonas()
551
+ controller.store.update((s) => {
552
+ s.personas = payload.personas
553
+ s.activePersona = payload.activeName
554
+ })
555
+ } catch {
556
+ // 预设列表加载失败不打断主配置界面
347
557
  }
348
- }, [showResetSuccess])
349
-
558
+ }
559
+
560
+ // 首次渲染拉取预设列表
561
+ React.useEffect(() => { loadPersonas() }, [])
562
+
350
563
  const handleSave = async () => {
351
- const controller = props.controller
352
- await controller.saveConfig({
353
- enabled: localEnabled,
354
- nickname: localNickname,
355
- occupation: localOccupation,
356
- bio: localBio,
357
- style: localStyle,
358
- headingLists: localHeadingLists,
359
- emoji: localEmoji,
360
- language: localLanguage,
361
- customInstructions: localInstructions
362
- })
363
- setShowSuccess(true)
564
+ const payload = await controller.saveConfig(localMap)
565
+ setToast({ text: payload && Array.isArray(payload.changed) && payload.changed.length > 0 ? t('toast.saved') : t('toast.noChanges'), kind: 'success' })
566
+ if (showPrompt) await loadPrompt()
364
567
  }
365
-
568
+
366
569
  const handleReset = async () => {
367
- const controller = props.controller
368
570
  await controller.resetConfig()
369
- setShowResetSuccess(true)
571
+ setToast({ text: t('toast.reset'), kind: 'success' })
572
+ if (showPrompt) await loadPrompt()
573
+ }
574
+
575
+ const handleSavePersona = async () => {
576
+ const name = personaName.trim()
577
+ if (!name) return
578
+ try {
579
+ await controller.savePersona(name)
580
+ setPersonaName('')
581
+ await loadPersonas()
582
+ setToast({ text: t('toast.personaSaved'), kind: 'success' })
583
+ } catch (err) {
584
+ setToast({ text: messageOf(err), kind: 'error' })
585
+ }
586
+ }
587
+
588
+ const handleUsePersona = async (name) => {
589
+ try {
590
+ const payload = await controller.usePersona(name)
591
+ await loadPersonas()
592
+ setToast({ text: payload && payload.unchanged ? t('toast.personaUnchanged') : t('toast.personaUsed'), kind: 'success' })
593
+ if (showPrompt) await loadPrompt()
594
+ } catch (err) {
595
+ setToast({ text: messageOf(err), kind: 'error' })
596
+ }
370
597
  }
371
-
598
+
599
+ const handleDeletePersona = async (name) => {
600
+ if (typeof globalThis.confirm === 'function' && !globalThis.confirm(t('personas.confirmDelete', { name }))) return
601
+ try {
602
+ await controller.deletePersona(name)
603
+ await loadPersonas()
604
+ setToast({ text: t('toast.personaDeleted'), kind: 'success' })
605
+ } catch (err) {
606
+ setToast({ text: messageOf(err), kind: 'error' })
607
+ }
608
+ }
609
+
610
+ const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
611
+ const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
612
+ const emojiOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.emoji.${value}`) }))
613
+ const personaNames = personas ? Object.keys(personas).sort() : null
614
+
615
+ const personaRowMeta = (entry) => {
616
+ const parts = []
617
+ if (entry && STYLE_VALUES.includes(entry.style)) parts.push(t(`style.${entry.style}`))
618
+ else if (entry && entry.style) parts.push(entry.style)
619
+ if (entry && entry.nickname) parts.push(entry.nickname)
620
+ return parts.join(' · ')
621
+ }
622
+
372
623
  return e('div', { className: 'soul-section' },
373
- e('h3', null, '个性化设置'),
624
+ e('h3', null, t('settings.title')),
374
625
  e('div', { className: 'soul-toggle' },
375
626
  e('input', {
376
627
  type: 'checkbox',
@@ -378,54 +629,54 @@ window.__ModuleLoader__.load({
378
629
  checked: localEnabled,
379
630
  onChange: (ev) => setLocalEnabled(ev.target.checked)
380
631
  }),
381
- e('label', { htmlFor: 'soul-enabled' }, '启用个性化设置')
632
+ e('label', { htmlFor: 'soul-enabled' }, t('toggle.enabled'))
382
633
  ),
383
-
634
+
384
635
  localEnabled && e(Fragment, null,
385
- e('div', { className: 'soul-group-title' }, '关于你'),
636
+ e('div', { className: 'soul-group-title' }, t('group.aboutYou')),
386
637
  e('div', { className: 'soul-field' },
387
- e('label', { htmlFor: 'soul-nickname' }, '用户昵称'),
638
+ e('label', { htmlFor: 'soul-nickname' }, t('field.nickname')),
388
639
  e('input', {
389
640
  type: 'text',
390
641
  id: 'soul-nickname',
391
642
  value: localNickname,
392
643
  onChange: (ev) => setLocalNickname(ev.target.value),
393
- placeholder: '输入你的昵称,回复时会称呼你'
644
+ placeholder: t('field.nicknamePlaceholder')
394
645
  })
395
646
  ),
396
647
  e('div', { className: 'soul-field' },
397
- e('label', { htmlFor: 'soul-occupation' }, '用户职业'),
648
+ e('label', { htmlFor: 'soul-occupation' }, t('field.occupation')),
398
649
  e('input', {
399
650
  type: 'text',
400
651
  id: 'soul-occupation',
401
652
  value: localOccupation,
402
653
  onChange: (ev) => setLocalOccupation(ev.target.value),
403
- placeholder: '例如:软件工程师、学生、产品经理'
654
+ placeholder: t('field.occupationPlaceholder')
404
655
  })
405
656
  ),
406
657
  e('div', { className: 'soul-field' },
407
- e('label', { htmlFor: 'soul-bio' }, '用户介绍'),
658
+ e('label', { htmlFor: 'soul-bio' }, t('field.bio')),
408
659
  e('textarea', {
409
660
  id: 'soul-bio',
410
661
  className: 'soul-textarea-sm',
411
662
  value: localBio,
412
663
  onChange: (ev) => setLocalBio(ev.target.value),
413
- placeholder: '简单介绍自己,让回复更贴合你的背景'
664
+ placeholder: t('field.bioPlaceholder')
414
665
  })
415
666
  ),
416
667
 
417
- e('div', { className: 'soul-group-title' }, '特质'),
668
+ e('div', { className: 'soul-group-title' }, t('group.traits')),
418
669
  e('div', { className: 'soul-field' },
419
670
  e('label', { htmlFor: 'soul-style' },
420
- '回复风格和语调',
421
- e(SoulHint, { text: '设置 Agent 回复你的风格和语调。这不会影响 Agent 的功能。' })
671
+ t('field.style'),
672
+ e(SoulHint, { text: t('hint.style') })
422
673
  ),
423
674
  e('select', {
424
675
  id: 'soul-style',
425
676
  value: localStyle,
426
677
  onChange: (ev) => setLocalStyle(ev.target.value)
427
678
  },
428
- ...STYLE_TONE_OPTIONS.map(opt =>
679
+ ...styleOptions.map(opt =>
429
680
  e('option', { key: opt.value, value: opt.value }, opt.label)
430
681
  )
431
682
  )
@@ -433,15 +684,15 @@ window.__ModuleLoader__.load({
433
684
 
434
685
  e('div', { className: 'soul-field' },
435
686
  e('label', { htmlFor: 'soul-headingLists' },
436
- '标题和列表',
437
- e(SoulHint, { text: '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中标题和列表的使用程度。' })
687
+ t('field.headingLists'),
688
+ e(SoulHint, { text: t('hint.headingLists') })
438
689
  ),
439
690
  e('select', {
440
691
  id: 'soul-headingLists',
441
692
  value: localHeadingLists,
442
693
  onChange: (ev) => setLocalHeadingLists(ev.target.value)
443
694
  },
444
- ...HEADING_LISTS_OPTIONS.map(opt =>
695
+ ...headingListsOptions.map(opt =>
445
696
  e('option', { key: opt.value, value: opt.value }, opt.label)
446
697
  )
447
698
  )
@@ -449,15 +700,15 @@ window.__ModuleLoader__.load({
449
700
 
450
701
  e('div', { className: 'soul-field' },
451
702
  e('label', { htmlFor: 'soul-emoji' },
452
- '表情符号',
453
- e(SoulHint, { text: '在回复风格和语调的基础上选择额外的自定义特质项。控制表情符号的使用程度。' })
703
+ t('field.emoji'),
704
+ e(SoulHint, { text: t('hint.emoji') })
454
705
  ),
455
706
  e('select', {
456
707
  id: 'soul-emoji',
457
708
  value: localEmoji,
458
709
  onChange: (ev) => setLocalEmoji(ev.target.value)
459
710
  },
460
- ...EMOJI_OPTIONS.map(opt =>
711
+ ...emojiOptions.map(opt =>
461
712
  e('option', { key: opt.value, value: opt.value }, opt.label)
462
713
  )
463
714
  )
@@ -465,8 +716,8 @@ window.__ModuleLoader__.load({
465
716
 
466
717
  e('div', { className: 'soul-field' },
467
718
  e('label', { htmlFor: 'soul-language' },
468
- '输出语言',
469
- e(SoulHint, { text: '设置 Agent 回复你使用的语言,同时影响 /soul 命令的输出语言。' })
719
+ t('field.language'),
720
+ e(SoulHint, { text: t('hint.language') })
470
721
  ),
471
722
  e('select', {
472
723
  id: 'soul-language',
@@ -481,78 +732,109 @@ window.__ModuleLoader__.load({
481
732
 
482
733
  e('div', { className: 'soul-field' },
483
734
  e('label', { htmlFor: 'soul-instructions' },
484
- '自定义指令',
485
- e(SoulHint, { text: '补充角色、习惯等个性化要求。建议不要重复设置回复风格和语调类似的话术,以免与上方选项冲突。' })
735
+ t('field.instructions'),
736
+ e(SoulHint, { text: t('hint.instructions') })
486
737
  ),
487
738
  e('textarea', {
488
739
  id: 'soul-instructions',
489
740
  value: localInstructions,
490
741
  onChange: (ev) => setLocalInstructions(ev.target.value),
491
- placeholder: '输入自定义指令,可以是其他行为、回复风格和语调等偏好设置'
742
+ placeholder: t('field.instructionsPlaceholder')
492
743
  })
744
+ ),
745
+
746
+ e('div', { className: 'soul-group-title' }, t('group.personas')),
747
+ e('div', { className: 'soul-field' },
748
+ personaNames === null
749
+ ? e('div', { className: 'soul-status' }, t('status.loading'))
750
+ : (personaNames.length === 0
751
+ ? e('div', { className: 'soul-status' }, t('personas.empty'))
752
+ : e(Fragment, null,
753
+ ...personaNames.map((name) => {
754
+ const entry = personas[name] || {}
755
+ return e('div', { className: 'soul-persona-row', key: name },
756
+ e('span', { className: 'soul-persona-name' }, name === activePersona ? `★ ${name}` : name),
757
+ e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
758
+ e('span', { className: 'soul-persona-actions' },
759
+ e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
760
+ e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
761
+ )
762
+ )
763
+ })
764
+ ))
765
+ ),
766
+ e('div', { className: 'soul-field soul-persona-save' },
767
+ e('input', {
768
+ type: 'text',
769
+ value: personaName,
770
+ maxLength: 30,
771
+ onChange: (ev) => setPersonaName(ev.target.value),
772
+ placeholder: t('personas.savePlaceholder')
773
+ }),
774
+ e(ui.Button, {
775
+ onClick: handleSavePersona,
776
+ disabled: saving || !personaName.trim()
777
+ }, t('personas.save'))
778
+ ),
779
+
780
+ e('div', { className: 'soul-group-title' }, t('group.tool')),
781
+ e('div', { className: 'soul-toggle' },
782
+ e('input', {
783
+ type: 'checkbox',
784
+ id: 'soul-toolConfirm',
785
+ checked: localToolConfirm,
786
+ onChange: (ev) => setLocalToolConfirm(ev.target.checked)
787
+ }),
788
+ e('label', { htmlFor: 'soul-toolConfirm' }, t('field.toolConfirm')),
789
+ e(SoulHint, { text: t('hint.toolConfirm') })
493
790
  )
494
791
  ),
495
-
792
+
496
793
  error && e('div', { className: 'soul-error' }, error),
497
-
794
+
498
795
  e('div', { className: 'soul-buttons' },
499
796
  e(ui.Button, {
500
797
  variant: 'primary',
501
798
  onClick: handleSave,
502
- disabled: saving
503
- }, saving ? '保存中...' : '保存设置'),
799
+ disabled: saving || !dirty
800
+ }, saving ? t('button.saving') : t('button.save')),
504
801
  e(ui.Button, {
505
802
  onClick: handleReset,
506
803
  disabled: saving
507
- }, '重置默认')
804
+ }, t('button.reset'))
508
805
  ),
509
-
510
- loading && e('div', { className: 'soul-status' }, '加载中...'),
511
-
512
- showSuccess && e('div', {
513
- className: 'soul-toast soul-toast-success',
514
- style: {
515
- animation: 'fadeInOut 2s ease-in-out'
516
- }
517
- }, '✅ 设置已保存'),
518
-
519
- showResetSuccess && e('div', {
520
- className: 'soul-toast soul-toast-success',
806
+
807
+ dirty && !saving && e('div', { className: 'soul-status soul-dirty' }, t('status.unsaved')),
808
+
809
+ e('div', { className: 'soul-prompt-toggle' },
810
+ e('button', {
811
+ type: 'button',
812
+ className: 'soul-prompt-link',
813
+ onClick: togglePrompt
814
+ }, showPrompt ? t('prompt.hide') : t('prompt.view'))
815
+ ),
816
+ showPrompt && e('div', { className: 'soul-prompt-panel' },
817
+ promptLoading
818
+ ? e('div', { className: 'soul-status' }, t('prompt.loading'))
819
+ : (promptError
820
+ ? e('div', { className: 'soul-error' }, promptError)
821
+ : e(Fragment, null,
822
+ e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
823
+ e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) }))
824
+ ))
825
+ ),
826
+
827
+ loading && e('div', { className: 'soul-status' }, t('status.loading')),
828
+
829
+ toast && e('div', {
830
+ className: `soul-toast ${toast.kind === 'error' ? 'soul-toast-error' : 'soul-toast-success'}`,
521
831
  style: {
522
832
  animation: 'fadeInOut 2s ease-in-out'
523
833
  }
524
- }, '✅ 已重置为默认值')
834
+ }, toast.text)
525
835
  )
526
836
  }
527
837
 
528
- // -------------------------------------------------------------------------
529
- // 文案
530
- // -------------------------------------------------------------------------
531
-
532
- const zh = {
533
- 'settings.title': '个性化设置',
534
- 'settings.description': '自定义Agent回复的风格和语调',
535
- 'button.save': '保存设置',
536
- 'button.reset': '重置默认',
537
- 'option.style': '回复风格和语调',
538
- 'option.instructions': '自定义指令',
539
- 'option.enabled': '启用个性化设置',
540
- 'status.loading': '加载中...',
541
- 'status.saving': '保存中...'
542
- }
543
-
544
- const en = {
545
- 'settings.title': 'Personalization Settings',
546
- 'settings.description': 'Customize agent response style and tone',
547
- 'button.save': 'Save Settings',
548
- 'button.reset': 'Reset to Default',
549
- 'option.style': 'Response Style & Tone',
550
- 'option.instructions': 'Custom Instructions',
551
- 'option.enabled': 'Enable Personalization',
552
- 'status.loading': 'Loading...',
553
- 'status.saving': 'Saving...'
554
- }
555
-
556
838
  // -------------------------------------------------------------------------
557
839
  // 插件装配
558
840
  // -------------------------------------------------------------------------
@@ -566,32 +848,41 @@ window.__ModuleLoader__.load({
566
848
  controller.dispose()
567
849
  }, 'dsh-soul: browser lifecycle')
568
850
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-soul: dictionaries')
569
-
851
+
852
+ // 暗色适配说明:原生 <select> 的弹出选项列表由浏览器按 color-scheme 渲染。
853
+ // 宿主主题呈现器会把 html 的 color-scheme 与 body[data-ds-dark-theme] 随主题
854
+ // 投影到 document(见 dsh-client-ui-layout ThemePresenter),因此这里用纯 CSS
855
+ // 属性选择器跟随即可,无需 JS 监听;选项行另用主题变量显式着色兜底。
856
+
857
+ // 导航 label / 图标替换用的翻译函数(bind 返回的函数在调用时读取当前语言)
858
+ const navT = ctx.locale.bind(NS)
859
+
570
860
  // DSH 0.1.x 不支持 settings.section 的 icon 字段
571
861
  // 使用 DOM 操作动态替换图标(类似 dsh-better-sidebar 的方案)
572
862
  const SOUL_NAV_MARKER = 'data-soul-settings-nav'
573
863
  const SOUL_ICON_MARKER = 'data-soul-icon-replaced'
574
-
864
+
575
865
  const registerSettingsNavIcon = () => {
576
866
  let disposed = false
577
-
867
+
578
868
  const sync = () => {
579
869
  if (disposed) return
580
-
870
+
581
871
  const buttons = document.querySelectorAll('[role="dialog"] nav button')
582
872
  for (const button of buttons) {
583
- if (button.textContent?.trim() === '个性化') {
873
+ // 匹配本栏目导航按钮:label 由 locale 词典渲染,双语均需识别
874
+ if (NAV_LABELS.has(button.textContent?.trim())) {
584
875
  button.setAttribute(SOUL_NAV_MARKER, '')
585
-
876
+
586
877
  // 检查是否已经替换过图标
587
878
  if (button.hasAttribute(SOUL_ICON_MARKER)) continue
588
-
879
+
589
880
  // 查找并替换图标
590
881
  const existingIcon = button.querySelector('svg')
591
882
  if (existingIcon) {
592
883
  // 标记已替换
593
884
  button.setAttribute(SOUL_ICON_MARKER, 'true')
594
-
885
+
595
886
  // 创建星形图标(跟随主题色)
596
887
  const starSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
597
888
  starSvg.setAttribute('viewBox', '0 0 24 24')
@@ -602,11 +893,11 @@ window.__ModuleLoader__.load({
602
893
  starSvg.setAttribute('stroke-linejoin', 'round')
603
894
  starSvg.style.width = '16px'
604
895
  starSvg.style.height = '16px'
605
-
896
+
606
897
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
607
898
  path.setAttribute('d', 'M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z')
608
899
  starSvg.appendChild(path)
609
-
900
+
610
901
  existingIcon.replaceWith(starSvg)
611
902
  }
612
903
  } else {
@@ -614,10 +905,10 @@ window.__ModuleLoader__.load({
614
905
  }
615
906
  }
616
907
  }
617
-
908
+
618
909
  // 延迟执行初始同步,等待设置页面渲染完成
619
910
  let timer = setTimeout(sync, 500)
620
-
911
+
621
912
  // 监听 DOM 变化(只监听子节点添加,不监听所有变化)
622
913
  const observer = new MutationObserver(() => {
623
914
  // 防抖:每次变化都重置计时器,停止变化 100ms 后执行
@@ -626,12 +917,12 @@ window.__ModuleLoader__.load({
626
917
  clearTimeout(timer)
627
918
  timer = setTimeout(sync, 100)
628
919
  })
629
-
920
+
630
921
  observer.observe(document.body, {
631
922
  childList: true,
632
923
  subtree: true
633
924
  })
634
-
925
+
635
926
  return () => {
636
927
  disposed = true
637
928
  clearTimeout(timer)
@@ -644,16 +935,16 @@ window.__ModuleLoader__.load({
644
935
  })
645
936
  }
646
937
  }
647
-
938
+
648
939
  // 注册图标替换效果
649
940
  ctx.effect(() => registerSettingsNavIcon(), 'dsh-soul: settings navigation icon')
650
-
651
- // 注册到设置页面
941
+
942
+ // 注册到设置页面(label 用 navT 实时解析;壳层在语言切换时会重新解析 label)
652
943
  ctx.slots.inject('settings.section', () => ctx.slots.register({
653
944
  name: 'settings.section',
654
945
  id: 'soul-settings',
655
946
  order: 50,
656
- label: () => '个性化',
947
+ label: () => navT('nav.label'),
657
948
  locale: NS,
658
949
  inject: () => ({
659
950
  hooks: {
@@ -662,7 +953,7 @@ window.__ModuleLoader__.load({
662
953
  controller
663
954
  })
664
955
  }, SoulSettings))
665
-
956
+
666
957
  // 加载初始配置
667
958
  controller.loadConfig()
668
959
  }
@@ -671,4 +962,4 @@ window.__ModuleLoader__.load({
671
962
  exports.inject = inject
672
963
  return module.exports
673
964
  },
674
- })
965
+ })