dsh-soul 0.4.0 → 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
@@ -3,7 +3,9 @@
3
3
  // 在设置页面添加「个性化」栏目:
4
4
  // - 启用/禁用个性化设置
5
5
  // - 「关于你」(昵称/职业/介绍)、回复风格和语调、特质、输出语言、自定义指令
6
- // - dirty 检测(无改动禁用保存)、保存结果提示(已保存 / 无变化)
6
+ // - 人设预设:保存当前为预设、一键使用(★ 标记当前匹配项)、删除
7
+ // - Agent 工具:set_persona 确认模式开关
8
+ // - dirty 检测(无改动禁用保存)、统一 toast 提示
7
9
  // - 查看当前生效提示词(只读)与字符数
8
10
  //
9
11
  // 纯 JS + React jsx-runtime 手写(不依赖 JSX 构建),开箱即用。
@@ -51,10 +53,15 @@ window.__ModuleLoader__.load({
51
53
  emoji: 'default',
52
54
  language: 'zh',
53
55
  customInstructions: '',
56
+ // set_persona 确认模式(v0.5.0)
57
+ requireToolConfirmation: false,
58
+ // 人设预设库与当前匹配项(GET /api/soul/personas 带回)
59
+ personas: null,
60
+ activePersona: null,
54
61
  loading: false,
55
62
  saving: false,
56
63
  error: null,
57
- // 最近一次保存实际变化的字段名数组(来自 POST /api/soul/config 的 changed)
64
+ // 最近一次保存实际变化的字段名数组(来自写路径的 changed)
58
65
  lastChanged: null
59
66
  }
60
67
 
@@ -94,29 +101,28 @@ window.__ModuleLoader__.load({
94
101
  this.disposed = false
95
102
  }
96
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
+
97
118
  async loadConfig() {
98
119
  if (this.disposed) return
99
120
  this.store.update(s => { s.loading = true; s.error = null })
100
121
 
101
122
  try {
102
- const url = new URL('/api/soul/config', hostBase())
103
- const response = await this.fetcher(url)
104
- const payload = await response.json().catch(() => ({}))
105
-
106
- if (!response.ok || payload.ok !== true) {
107
- throw new Error(payload.error || `HTTP ${response.status}`)
108
- }
109
-
123
+ const payload = await this.postJSON('/api/soul/config', null, { method: 'GET' })
110
124
  this.store.update(s => {
111
- s.enabled = payload.config.enabled
112
- s.nickname = payload.config.nickname || ''
113
- s.occupation = payload.config.occupation || ''
114
- s.bio = payload.config.bio || ''
115
- s.style = payload.config.style
116
- s.headingLists = payload.config.headingLists || 'default'
117
- s.emoji = payload.config.emoji || 'default'
118
- s.language = payload.config.language || 'zh'
119
- s.customInstructions = payload.config.customInstructions
125
+ this.applyConfig(s, payload.config)
120
126
  s.loading = false
121
127
  })
122
128
  } catch (error) {
@@ -133,31 +139,13 @@ window.__ModuleLoader__.load({
133
139
  this.store.update(s => { s.saving = true; s.error = null })
134
140
 
135
141
  try {
136
- const url = new URL('/api/soul/config', hostBase())
137
- const response = await this.fetcher(url, {
138
- method: 'POST',
139
- headers: { 'content-type': 'application/json' },
140
- body: JSON.stringify(config)
141
- })
142
- const payload = await response.json().catch(() => ({}))
143
-
144
- if (!response.ok || payload.ok !== true) {
145
- throw new Error(payload.error || `HTTP ${response.status}`)
146
- }
147
-
142
+ const payload = await this.postJSON('/api/soul/config', config)
148
143
  this.store.update(s => {
149
- s.enabled = payload.config.enabled
150
- s.nickname = payload.config.nickname || ''
151
- s.occupation = payload.config.occupation || ''
152
- s.bio = payload.config.bio || ''
153
- s.style = payload.config.style
154
- s.headingLists = payload.config.headingLists || 'default'
155
- s.emoji = payload.config.emoji || 'default'
156
- s.language = payload.config.language || 'zh'
157
- s.customInstructions = payload.config.customInstructions
144
+ this.applyConfig(s, payload.config)
158
145
  s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
159
146
  s.saving = false
160
147
  })
148
+ return payload
161
149
  } catch (error) {
162
150
  if (this.disposed) return
163
151
  this.store.update(s => {
@@ -172,27 +160,13 @@ window.__ModuleLoader__.load({
172
160
  this.store.update(s => { s.saving = true; s.error = null })
173
161
 
174
162
  try {
175
- const url = new URL('/api/soul/config/reset', hostBase())
176
- const response = await this.fetcher(url, { method: 'POST' })
177
- const payload = await response.json().catch(() => ({}))
178
-
179
- if (!response.ok || payload.ok !== true) {
180
- throw new Error(payload.error || `HTTP ${response.status}`)
181
- }
182
-
163
+ const payload = await this.postJSON('/api/soul/config/reset')
183
164
  this.store.update(s => {
184
- s.enabled = payload.config.enabled
185
- s.nickname = payload.config.nickname || ''
186
- s.occupation = payload.config.occupation || ''
187
- s.bio = payload.config.bio || ''
188
- s.style = payload.config.style
189
- s.headingLists = payload.config.headingLists || 'default'
190
- s.emoji = payload.config.emoji || 'default'
191
- s.language = payload.config.language || 'zh'
192
- s.customInstructions = payload.config.customInstructions
165
+ this.applyConfig(s, payload.config)
193
166
  s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
194
167
  s.saving = false
195
168
  })
169
+ return payload
196
170
  } catch (error) {
197
171
  if (this.disposed) return
198
172
  this.store.update(s => {
@@ -202,10 +176,39 @@ window.__ModuleLoader__.load({
202
176
  }
203
177
  }
204
178
 
205
- // 读取当前生效(已保存)的提示词:{ ok, prompt, enabled }
206
- async fetchPrompt() {
207
- const url = new URL('/api/soul/prompt', hostBase())
208
- const response = await this.fetcher(url)
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)
209
212
  const payload = await response.json().catch(() => ({}))
210
213
  if (!response.ok || payload.ok !== true) {
211
214
  throw new Error(payload.error || `HTTP ${response.status}`)
@@ -213,6 +216,12 @@ window.__ModuleLoader__.load({
213
216
  return payload
214
217
  }
215
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
+
216
225
  async dispose() {
217
226
  this.disposed = true
218
227
  }
@@ -223,31 +232,44 @@ window.__ModuleLoader__.load({
223
232
  // -------------------------------------------------------------------------
224
233
 
225
234
  const css = [
226
- '.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)}',
227
236
  '.soul-section h3{margin:0 0 16px 0;font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
228
237
  '.soul-field{margin-bottom:16px;width:100%}',
229
238
  '.soul-field label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
230
- '.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}',
231
245
  '.soul-field textarea{min-height:100px;resize:vertical}',
232
246
  '.soul-field textarea.soul-textarea-sm{min-height:60px}',
233
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)}',
234
- '.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)}',
235
249
  '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:16px}',
236
250
  '.soul-toggle label{margin:0;font-size:13px;color:var(--dsw-alias-label-primary)}',
237
251
  '.soul-buttons{display:flex;gap:8px;margin-top:16px}',
238
- '.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}',
239
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}',
240
254
  '.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
241
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)}',
242
256
  '.soul-hint:hover .soul-hint-tip{opacity:1;visibility:visible}',
243
257
  '.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
244
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}',
245
265
  '.soul-prompt-toggle{margin-top:8px}',
246
266
  '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
247
267
  '.soul-prompt-link:hover{color:var(--dsw-alias-label-primary)}',
248
- '.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-l1);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}',
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}',
249
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}',
250
271
  '.soul-toast-success{background:#f6ffed;border:1px solid #b7eb8f;color:#52c41a}',
272
+ '.soul-toast-error{background:#fff1f0;border:1px solid #ffa39e;color:#f5222d}',
251
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)}}'
252
274
  ].join('')
253
275
 
@@ -295,6 +317,13 @@ window.__ModuleLoader__.load({
295
317
  'field.instructions': '自定义指令',
296
318
  'hint.instructions': '补充角色、习惯等个性化要求。建议不要重复设置回复风格和语调类似的话术,以免与上方选项冲突。',
297
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': '暂无人设预设,保存当前配置后可一键切换',
298
327
  'style.professional': '专业严谨',
299
328
  'style.casual': '轻松自然',
300
329
  'style.humorous': '幽默风趣',
@@ -306,6 +335,9 @@ window.__ModuleLoader__.load({
306
335
  'trait.emoji.default': '默认',
307
336
  'trait.emoji.more': '增强(使用较多表情符号)',
308
337
  'trait.emoji.less': '减弱(尽量减少使用表情符号)',
338
+ 'group.tool': 'Agent 工具',
339
+ 'field.toolConfirm': '人设变更需确认',
340
+ 'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
309
341
  'button.save': '保存设置',
310
342
  'button.saving': '保存中...',
311
343
  'button.reset': '重置默认',
@@ -314,6 +346,10 @@ window.__ModuleLoader__.load({
314
346
  'toast.saved': '✅ 设置已保存',
315
347
  'toast.noChanges': '✅ 配置无变化',
316
348
  'toast.reset': '✅ 已重置为默认值',
349
+ 'toast.personaSaved': '✅ 预设已保存',
350
+ 'toast.personaUsed': '✅ 预设已应用',
351
+ 'toast.personaUnchanged': 'ℹ 预设与当前配置一致',
352
+ 'toast.personaDeleted': '✅ 预设已删除',
317
353
  'prompt.view': '查看当前生效提示词',
318
354
  'prompt.hide': '收起提示词',
319
355
  'prompt.loading': '提示词加载中...',
@@ -344,6 +380,13 @@ window.__ModuleLoader__.load({
344
380
  'field.instructions': 'Custom instructions',
345
381
  'hint.instructions': 'Extra persona or habit requirements. Avoid repeating wording similar to the style & tone option above to prevent conflicts.',
346
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',
347
390
  'style.professional': 'Professional',
348
391
  'style.casual': 'Casual',
349
392
  'style.humorous': 'Humorous',
@@ -355,6 +398,9 @@ window.__ModuleLoader__.load({
355
398
  'trait.emoji.default': 'Default',
356
399
  'trait.emoji.more': 'More (frequent emoji usage)',
357
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.',
358
404
  'button.save': 'Save Settings',
359
405
  'button.saving': 'Saving...',
360
406
  'button.reset': 'Reset to Default',
@@ -363,6 +409,10 @@ window.__ModuleLoader__.load({
363
409
  'toast.saved': '✅ Settings saved',
364
410
  'toast.noChanges': '✅ No changes to save',
365
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',
366
416
  'prompt.view': 'View the active system prompt',
367
417
  'prompt.hide': 'Hide the prompt',
368
418
  'prompt.loading': 'Loading prompt...',
@@ -401,7 +451,7 @@ window.__ModuleLoader__.load({
401
451
  ]
402
452
 
403
453
  // 表单字段与 store 字段的一一对应(dirty 检测与保存载荷共用)
404
- const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions']
454
+ const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions', 'requireToolConfirmation']
405
455
 
406
456
  // 提示词小图标:hover 展示说明文字
407
457
  function SoulHint(props) {
@@ -430,7 +480,7 @@ window.__ModuleLoader__.load({
430
480
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
431
481
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
432
482
  const state = useSoulController((state) => state)
433
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, loading, saving, error, lastChanged } = state
483
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, personas, activePersona, loading, saving, error } = state
434
484
 
435
485
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
436
486
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
@@ -441,8 +491,9 @@ window.__ModuleLoader__.load({
441
491
  const [localEmoji, setLocalEmoji] = React.useState(emoji)
442
492
  const [localLanguage, setLocalLanguage] = React.useState(language || 'zh')
443
493
  const [localInstructions, setLocalInstructions] = React.useState(customInstructions)
444
- const [showSuccess, setShowSuccess] = React.useState(false)
445
- const [showResetSuccess, setShowResetSuccess] = React.useState(false)
494
+ const [localToolConfirm, setLocalToolConfirm] = React.useState(requireToolConfirmation === true)
495
+ const [personaName, setPersonaName] = React.useState('')
496
+ const [toast, setToast] = React.useState(null)
446
497
  const [showPrompt, setShowPrompt] = React.useState(false)
447
498
  const [promptText, setPromptText] = React.useState('')
448
499
  const [promptLoading, setPromptLoading] = React.useState(false)
@@ -459,27 +510,20 @@ window.__ModuleLoader__.load({
459
510
  setLocalEmoji(emoji)
460
511
  setLocalLanguage(language || 'zh')
461
512
  setLocalInstructions(customInstructions)
462
- }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions])
463
-
464
- // 保存成功后显示提示,2秒后自动消失
465
- React.useEffect(() => {
466
- if (showSuccess) {
467
- const timer = setTimeout(() => setShowSuccess(false), 2000)
468
- return () => clearTimeout(timer)
469
- }
470
- }, [showSuccess])
513
+ setLocalToolConfirm(requireToolConfirmation === true)
514
+ }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation])
471
515
 
472
- // 重置成功后显示提示,2秒后自动消失
516
+ // toast 自动消失
473
517
  React.useEffect(() => {
474
- if (showResetSuccess) {
475
- const timer = setTimeout(() => setShowResetSuccess(false), 2000)
518
+ if (toast) {
519
+ const timer = setTimeout(() => setToast(null), 2000)
476
520
  return () => clearTimeout(timer)
477
521
  }
478
- }, [showResetSuccess])
522
+ }, [toast])
479
523
 
480
524
  // dirty 检测:本地表单与已保存配置逐字段比较
481
- const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions }
482
- const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, language: localLanguage, customInstructions: localInstructions }
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 }
483
527
  const dirty = FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
484
528
 
485
529
  const loadPrompt = async () => {
@@ -501,22 +545,80 @@ window.__ModuleLoader__.load({
501
545
  if (next) await loadPrompt()
502
546
  }
503
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
+ // 预设列表加载失败不打断主配置界面
557
+ }
558
+ }
559
+
560
+ // 首次渲染拉取预设列表
561
+ React.useEffect(() => { loadPersonas() }, [])
562
+
504
563
  const handleSave = async () => {
505
- await controller.saveConfig(localMap)
506
- 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' })
507
566
  if (showPrompt) await loadPrompt()
508
567
  }
509
568
 
510
569
  const handleReset = async () => {
511
570
  await controller.resetConfig()
512
- setShowResetSuccess(true)
571
+ setToast({ text: t('toast.reset'), kind: 'success' })
513
572
  if (showPrompt) await loadPrompt()
514
573
  }
515
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
+ }
597
+ }
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
+
516
610
  const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
517
611
  const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
518
612
  const emojiOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.emoji.${value}`) }))
519
- const savedToastText = Array.isArray(lastChanged) && lastChanged.length > 0 ? t('toast.saved') : t('toast.noChanges')
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
+ }
520
622
 
521
623
  return e('div', { className: 'soul-section' },
522
624
  e('h3', null, t('settings.title')),
@@ -639,6 +741,52 @@ window.__ModuleLoader__.load({
639
741
  onChange: (ev) => setLocalInstructions(ev.target.value),
640
742
  placeholder: t('field.instructionsPlaceholder')
641
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') })
642
790
  )
643
791
  ),
644
792
 
@@ -678,19 +826,12 @@ window.__ModuleLoader__.load({
678
826
 
679
827
  loading && e('div', { className: 'soul-status' }, t('status.loading')),
680
828
 
681
- showSuccess && e('div', {
682
- className: 'soul-toast soul-toast-success',
829
+ toast && e('div', {
830
+ className: `soul-toast ${toast.kind === 'error' ? 'soul-toast-error' : 'soul-toast-success'}`,
683
831
  style: {
684
832
  animation: 'fadeInOut 2s ease-in-out'
685
833
  }
686
- }, savedToastText),
687
-
688
- showResetSuccess && e('div', {
689
- className: 'soul-toast soul-toast-success',
690
- style: {
691
- animation: 'fadeInOut 2s ease-in-out'
692
- }
693
- }, t('toast.reset'))
834
+ }, toast.text)
694
835
  )
695
836
  }
696
837
 
@@ -708,6 +849,11 @@ window.__ModuleLoader__.load({
708
849
  }, 'dsh-soul: browser lifecycle')
709
850
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-soul: dictionaries')
710
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
+
711
857
  // 导航 label / 图标替换用的翻译函数(bind 返回的函数在调用时读取当前语言)
712
858
  const navT = ctx.locale.bind(NS)
713
859