dsh-soul 0.4.0 → 0.6.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,9 +3,15 @@
3
3
  // 在设置页面添加「个性化」栏目:
4
4
  // - 启用/禁用个性化设置
5
5
  // - 「关于你」(昵称/职业/介绍)、回复风格和语调、特质、输出语言、自定义指令
6
- // - dirty 检测(无改动禁用保存)、保存结果提示(已保存 / 无变化)
6
+ // - 人设预设:保存当前为预设、一键使用(★ 标记当前匹配项)、删除
7
+ // - Agent 工具:set_persona 确认模式开关
8
+ // - 输入框光轨:Agent 回复中时输入框边框的流光动效(颜色 / 速度 / 粗细 + 实时示例)
9
+ // - dirty 检测(无改动禁用保存)、统一 toast 提示
7
10
  // - 查看当前生效提示词(只读)与字符数
8
11
  //
12
+ // 光轨另注册到 conversation.input.overlay 槽位(输入框卡片内部),
13
+ // 运行时按会话 running 状态在卡片上挂载/隐藏 SVG 环,见 SoulTrail。
14
+ //
9
15
  // 纯 JS + React jsx-runtime 手写(不依赖 JSX 构建),开箱即用。
10
16
  // 注意:dsh web 使用 React 17+ 的 jsx-runtime,children 必须放在 props.children
11
17
  // 里;不能把 children 当第三个参数传(第三个参数是 key)。
@@ -41,6 +47,7 @@ window.__ModuleLoader__.load({
41
47
  // -------------------------------------------------------------------------
42
48
 
43
49
  const NS = 'soul'
50
+ const VERSION = '0.6.0'
44
51
  const INITIAL = {
45
52
  enabled: true,
46
53
  nickname: '',
@@ -51,10 +58,20 @@ window.__ModuleLoader__.load({
51
58
  emoji: 'default',
52
59
  language: 'zh',
53
60
  customInstructions: '',
61
+ // set_persona 确认模式(v0.5.0)
62
+ requireToolConfirmation: false,
63
+ // 输入框光轨(v0.6.0):Agent 回复中时输入框边框的流光动效
64
+ trailEnabled: true,
65
+ trailColor: '#679EFE',
66
+ trailSpeed: 'slow',
67
+ trailWidth: 'thin',
68
+ // 人设预设库与当前匹配项(GET /api/soul/personas 带回)
69
+ personas: null,
70
+ activePersona: null,
54
71
  loading: false,
55
72
  saving: false,
56
73
  error: null,
57
- // 最近一次保存实际变化的字段名数组(来自 POST /api/soul/config 的 changed)
74
+ // 最近一次保存实际变化的字段名数组(来自写路径的 changed)
58
75
  lastChanged: null
59
76
  }
60
77
 
@@ -87,6 +104,95 @@ window.__ModuleLoader__.load({
87
104
  return error instanceof Error ? error.message : String(error)
88
105
  }
89
106
 
107
+ // -------------------------------------------------------------------------
108
+ // 输入框光轨:渲染层
109
+ // -------------------------------------------------------------------------
110
+ //
111
+ // 匀速原理:沿「卡片边缘」画一个 SVG 圆角矩形,pathLength 归一化到 100,
112
+ // 用 stroke-dashoffset 线性动画推进 —— 按弧长运动,圆角与直边线速度一致
113
+ // (旋转 conic-gradient 的角速度恒定,但周长线速度会随位置变化,故不采用)。
114
+ // 渐隐拖尾:8 层等长 dash 依次错位叠加、透明度递减 —— 头部各层重叠最亮,
115
+ // 向后剩余层数递减形成拖尾;所有层共用同一时长,整体仍严格匀速。
116
+ // 与边框重合:stroke 中心线落在容器边框盒边缘(rect 即 0,0,w,h),
117
+ // 并靠 overflow:visible 让外半圈可见;光轨开启时隐藏宿主自身细边框,
118
+ // 保证边界上只有一条线。
119
+
120
+ const TRAIL_DASH = 12
121
+ const TRAIL_LAYERS = 8
122
+ const TRAIL_LAYER_STEP = 2.2
123
+ const TRAIL_RADIUS = 22
124
+ const TRAIL_COLOR_FALLBACK = '#679EFE'
125
+ const TRAIL_PRESET_COLORS = ['#679EFE', '#22C55E', '#EF4444', '#A855F7', '#06B6D4', '#F59E0B']
126
+ const TRAIL_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/
127
+ const TRAIL_SPEED_VALUES = ['slow', 'normal', 'fast']
128
+ const TRAIL_WIDTH_VALUES = ['thin', 'normal', 'thick']
129
+
130
+ // 颜色容错:输入中途(如只打了 #6)回退到默认色,避免示例与提示丢失;
131
+ // 统一大写,与宿主归一化结果及调色板取值一致
132
+ function safeTrailColor(value) {
133
+ if (typeof value !== 'string') return TRAIL_COLOR_FALLBACK
134
+ const text = value.trim()
135
+ return TRAIL_COLOR_PATTERN.test(text) ? text.toUpperCase() : TRAIL_COLOR_FALLBACK
136
+ }
137
+
138
+ function createSvgNode(tag, attrs) {
139
+ const node = document.createElementNS('http://www.w3.org/2000/svg', tag)
140
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value)
141
+ return node
142
+ }
143
+
144
+ // 构建拖尾环;层数、dash 长度与错位步长与 CSS 中的 keyframes 一一对应
145
+ function buildTrailSvg() {
146
+ const svg = createSvgNode('svg', { class: 'soul-trail-svg', 'aria-hidden': 'true' })
147
+ for (let layer = 0; layer < TRAIL_LAYERS; layer++) {
148
+ svg.appendChild(createSvgNode('rect', {
149
+ class: 'soul-trail-layer',
150
+ 'data-layer': String(layer),
151
+ fill: 'none',
152
+ pathLength: '100',
153
+ 'stroke-dasharray': `${TRAIL_DASH} ${100 - TRAIL_DASH}`,
154
+ 'stroke-dashoffset': String(layer * TRAIL_LAYER_STEP)
155
+ }))
156
+ }
157
+ return svg
158
+ }
159
+
160
+ // 让环与容器像素尺寸一致(viewBox 用像素),圆角半径与宿主输入框卡片一致
161
+ function sizeTrailSvg(svg, width, height) {
162
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
163
+ if (svg.dataset.trailWidth === String(width) && svg.dataset.trailHeight === String(height)) return
164
+ svg.dataset.trailWidth = String(width)
165
+ svg.dataset.trailHeight = String(height)
166
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
167
+ for (const rect of svg.querySelectorAll('.soul-trail-layer')) {
168
+ rect.setAttribute('x', '0')
169
+ rect.setAttribute('y', '0')
170
+ rect.setAttribute('width', String(width))
171
+ rect.setAttribute('height', String(height))
172
+ rect.setAttribute('rx', String(TRAIL_RADIUS))
173
+ }
174
+ }
175
+
176
+ // 把环挂到宿主元素上并跟随其尺寸变化(输入框随内容增高时环不变形);返回卸载函数
177
+ function mountTrailRing(host) {
178
+ const svg = buildTrailSvg()
179
+ host.appendChild(svg)
180
+ const sync = () => sizeTrailSvg(svg, host.clientWidth, host.clientHeight)
181
+ sync()
182
+ let observer = null
183
+ if (typeof ResizeObserver === 'function') {
184
+ observer = new ResizeObserver(sync)
185
+ observer.observe(host)
186
+ } else if (typeof globalThis.addEventListener === 'function') {
187
+ globalThis.addEventListener('resize', sync)
188
+ }
189
+ return () => {
190
+ if (observer) observer.disconnect()
191
+ else if (typeof globalThis.removeEventListener === 'function') globalThis.removeEventListener('resize', sync)
192
+ svg.remove()
193
+ }
194
+ }
195
+
90
196
  var SoulController = class {
91
197
  constructor(fetcher = (input, init) => fetch(input, init)) {
92
198
  this.fetcher = fetcher
@@ -94,29 +200,32 @@ window.__ModuleLoader__.load({
94
200
  this.disposed = false
95
201
  }
96
202
 
203
+ // 把服务端返回的完整配置映射进 store
204
+ applyConfig(s, config) {
205
+ s.enabled = config.enabled
206
+ s.nickname = config.nickname || ''
207
+ s.occupation = config.occupation || ''
208
+ s.bio = config.bio || ''
209
+ s.style = config.style
210
+ s.headingLists = config.headingLists || 'default'
211
+ s.emoji = config.emoji || 'default'
212
+ s.language = config.language || 'zh'
213
+ s.customInstructions = config.customInstructions
214
+ s.requireToolConfirmation = config.requireToolConfirmation === true
215
+ s.trailEnabled = config.trailEnabled !== false
216
+ s.trailColor = config.trailColor || TRAIL_COLOR_FALLBACK
217
+ s.trailSpeed = config.trailSpeed || 'slow'
218
+ s.trailWidth = config.trailWidth || 'thin'
219
+ }
220
+
97
221
  async loadConfig() {
98
222
  if (this.disposed) return
99
223
  this.store.update(s => { s.loading = true; s.error = null })
100
224
 
101
225
  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
-
226
+ const payload = await this.postJSON('/api/soul/config', null, { method: 'GET' })
110
227
  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
228
+ this.applyConfig(s, payload.config)
120
229
  s.loading = false
121
230
  })
122
231
  } catch (error) {
@@ -133,31 +242,13 @@ window.__ModuleLoader__.load({
133
242
  this.store.update(s => { s.saving = true; s.error = null })
134
243
 
135
244
  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
-
245
+ const payload = await this.postJSON('/api/soul/config', config)
148
246
  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
247
+ this.applyConfig(s, payload.config)
158
248
  s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
159
249
  s.saving = false
160
250
  })
251
+ return payload
161
252
  } catch (error) {
162
253
  if (this.disposed) return
163
254
  this.store.update(s => {
@@ -172,27 +263,13 @@ window.__ModuleLoader__.load({
172
263
  this.store.update(s => { s.saving = true; s.error = null })
173
264
 
174
265
  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
-
266
+ const payload = await this.postJSON('/api/soul/config/reset')
183
267
  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
268
+ this.applyConfig(s, payload.config)
193
269
  s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
194
270
  s.saving = false
195
271
  })
272
+ return payload
196
273
  } catch (error) {
197
274
  if (this.disposed) return
198
275
  this.store.update(s => {
@@ -202,10 +279,39 @@ window.__ModuleLoader__.load({
202
279
  }
203
280
  }
204
281
 
205
- // 读取当前生效(已保存)的提示词:{ ok, prompt, enabled }
206
- async fetchPrompt() {
207
- const url = new URL('/api/soul/prompt', hostBase())
208
- const response = await this.fetcher(url)
282
+ // 人设预设:列表 / 保存 / 使用 / 删除
283
+ async fetchPersonas() {
284
+ return await this.postJSON('/api/soul/personas', null, { method: 'GET' })
285
+ }
286
+
287
+ async savePersona(name) {
288
+ return await this.postJSON('/api/soul/personas/save', { name })
289
+ }
290
+
291
+ async usePersona(name) {
292
+ const payload = await this.postJSON('/api/soul/personas/use', { name })
293
+ if (!this.disposed) {
294
+ this.store.update(s => {
295
+ this.applyConfig(s, payload.config)
296
+ s.lastChanged = Array.isArray(payload.changed) ? payload.changed : []
297
+ })
298
+ }
299
+ return payload
300
+ }
301
+
302
+ async deletePersona(name) {
303
+ return await this.postJSON('/api/soul/personas/delete', { name })
304
+ }
305
+
306
+ // 统一请求:body 为 null 时不携带请求体(GET 用)
307
+ async postJSON(path, body, overrides = {}) {
308
+ const url = new URL(path, hostBase())
309
+ const init = { method: overrides.method || 'POST', ...overrides }
310
+ if (body !== null && body !== undefined) {
311
+ init.headers = { 'content-type': 'application/json' }
312
+ init.body = JSON.stringify(body)
313
+ }
314
+ const response = await this.fetcher(url, init)
209
315
  const payload = await response.json().catch(() => ({}))
210
316
  if (!response.ok || payload.ok !== true) {
211
317
  throw new Error(payload.error || `HTTP ${response.status}`)
@@ -213,6 +319,12 @@ window.__ModuleLoader__.load({
213
319
  return payload
214
320
  }
215
321
 
322
+ // 读取当前生效(已保存)的提示词:{ ok, prompt, enabled }
323
+ async fetchPrompt() {
324
+ const payload = await this.postJSON('/api/soul/prompt', null, { method: 'GET' })
325
+ return payload
326
+ }
327
+
216
328
  async dispose() {
217
329
  this.disposed = true
218
330
  }
@@ -223,32 +335,112 @@ window.__ModuleLoader__.load({
223
335
  // -------------------------------------------------------------------------
224
336
 
225
337
  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)}',
338
+ '.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
339
  '.soul-section h3{margin:0 0 16px 0;font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
340
+ '.soul-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}',
341
+ '.soul-version{flex:0 0 auto;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400;line-height:1.4;white-space:nowrap}',
228
342
  '.soul-field{margin-bottom:16px;width:100%}',
229
343
  '.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}',
344
+ '.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}',
345
+ '.soul-field select option{color:var(--dsw-alias-label-primary)}',
346
+ '.soul-section{color-scheme:light}',
347
+ 'body[data-ds-dark-theme] .soul-section{color-scheme:dark}',
348
+ '.soul-field select{color-scheme:light}',
349
+ 'body[data-ds-dark-theme] .soul-field select{color-scheme:dark}',
231
350
  '.soul-field textarea{min-height:100px;resize:vertical}',
232
351
  '.soul-field textarea.soul-textarea-sm{min-height:60px}',
233
352
  '.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)}',
353
+ '.soul-accordion{margin:12px 0;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;overflow:visible;background:var(--dsw-alias-bg-layer-1)}',
354
+ '.soul-accordion-open{position:relative;z-index:20}',
355
+ // 展开时标题行下沿改直角:与下方内容区形成一条清晰的水平交界,
356
+ // 避免"标题与内容同为一片底色、分不清哪块是标题"
357
+ '.soul-accordion-open .soul-accordion-trigger{border-radius:5px 5px 0 0}',
358
+ '.soul-accordion-trigger{display:flex;align-items:center;width:100%;min-height:44px;gap:10px;padding:10px 12px;border:0;border-radius:5px;background:var(--dsw-alias-interactive-bg-active,rgba(127,127,127,0.12));color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;transition:background-color .15s ease}',
359
+ '.soul-accordion-trigger:hover{background:var(--dsw-alias-interactive-bg-hover-accent,rgba(127,127,127,0.2))}',
360
+ '.soul-accordion-title{flex:0 0 auto;font-size:13px;font-weight:600}',
361
+ '.soul-accordion-summary{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400}',
362
+ '.soul-accordion-icon{flex:0 0 18px;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:1;text-align:center}',
363
+ '.soul-accordion-body{padding:12px;border-top:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)}',
364
+ '.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
365
  '.soul-toggle{display:flex;align-items:center;gap:8px;margin-bottom:16px}',
236
366
  '.soul-toggle label{margin:0;font-size:13px;color:var(--dsw-alias-label-primary)}',
237
367
  '.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}',
368
+ '.soul-quick-toggle{position:relative;isolation:isolate;display:inline-flex;align-items:center;gap:6px;min-height:28px;overflow:hidden;padding:4px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1;cursor:pointer;white-space:nowrap}',
369
+ '.soul-quick-toggle:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
370
+ '.soul-quick-toggle[data-enabled="true"]{color:var(--dsw-alias-brand-primary)}',
371
+ '.soul-quick-toggle[data-enabled="true"]:hover{border-color:#ef4444;color:#ef4444;box-shadow:0 0 0 2px rgba(239,68,68,.18)}',
372
+ '.soul-quick-toggle[data-enabled="false"]:hover{border-color:#22c55e;color:#22c55e;box-shadow:0 0 0 2px rgba(34,197,94,.18)}',
373
+ '.soul-quick-toggle[data-enabled="true"]:hover::before{background:rgba(239,68,68,.24);animation:soul-toggle-red-sweep .55s ease-out both}',
374
+ '.soul-quick-toggle[data-enabled="false"]:hover::before{background:rgba(34,197,94,.24);animation:soul-toggle-green-sweep .55s ease-out both}',
375
+ '.soul-quick-toggle::before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;transform:translateX(100%)}',
376
+ '.soul-quick-toggle > *{position:relative;z-index:1}',
377
+ '.soul-quick-toggle-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}',
378
+ '.soul-quick-toggle[data-enabled="true"] .soul-quick-toggle-dot{background:var(--dsw-static-green-500,#22c55e);animation:soul-status-pulse 1.8s ease-in-out infinite}',
379
+ '.soul-quick-toggle:disabled{cursor:wait;opacity:.6}',
380
+ '@keyframes soul-toggle-red-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
381
+ '@keyframes soul-toggle-green-sweep{from{transform:translateX(100%)}to{transform:translateX(-100%)}}',
382
+ '@keyframes soul-status-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-static-green-500,#22c55e) 35%,transparent);opacity:.75}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--dsw-static-green-500,#22c55e) 0%,transparent);opacity:1}}',
383
+ '@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}}',
384
+ '.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
385
  '.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
386
  '.soul-hint:hover{color:var(--dsw-alias-label-primary)}',
241
387
  '.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
388
  '.soul-hint:hover .soul-hint-tip{opacity:1;visibility:visible}',
243
389
  '.soul-status{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
244
390
  '.soul-dirty{color:#d46b08}',
245
- '.soul-prompt-toggle{margin-top:8px}',
391
+ '.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}',
392
+ '.soul-persona-name{color:var(--dsw-alias-label-primary);font-weight:500;white-space:nowrap}',
393
+ '.soul-persona-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
394
+ '.soul-persona-actions{display:flex;gap:10px;white-space:nowrap}',
395
+ '.soul-persona-save{display:flex;gap:8px}',
396
+ '.soul-persona-save input[type=text]{flex:1}',
246
397
  '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
247
398
  '.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}',
399
+ '.soul-persona-danger{color:var(--dsw-alias-label-error)}',
400
+ '.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
401
  '.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
402
  '.soul-toast-success{background:#f6ffed;border:1px solid #b7eb8f;color:#52c41a}',
251
- '@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)}}'
403
+ '.soul-toast-error{background:#fff1f0;border:1px solid #ffa39e;color:#f5222d}',
404
+ '@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)}}',
405
+ // —— 输入框光轨 ——
406
+ // 环挂在卡片上:stroke 中心线即卡片边缘,外半圈靠 overflow:visible 显示
407
+ '.soul-trail-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}',
408
+ // 速度档位(整体比首版慢一档):normal 3.6s / slow 4.8s / fast 2.4s
409
+ '.soul-trail-layer{fill:none;stroke:var(--soul-trail-color,#679EFE);stroke-width:2.5;stroke-linecap:butt;animation-name:soul-trail-run-0;animation-duration:3.6s;animation-timing-function:linear;animation-iteration-count:infinite}',
410
+ '.soul-trail-layer[data-layer="0"]{opacity:1;stroke-linecap:round}',
411
+ '.soul-trail-layer[data-layer="1"]{animation-name:soul-trail-run-1;opacity:.87}',
412
+ '.soul-trail-layer[data-layer="2"]{animation-name:soul-trail-run-2;opacity:.74}',
413
+ '.soul-trail-layer[data-layer="3"]{animation-name:soul-trail-run-3;opacity:.61}',
414
+ '.soul-trail-layer[data-layer="4"]{animation-name:soul-trail-run-4;opacity:.47}',
415
+ '.soul-trail-layer[data-layer="5"]{animation-name:soul-trail-run-5;opacity:.34}',
416
+ '.soul-trail-layer[data-layer="6"]{animation-name:soul-trail-run-6;opacity:.21}',
417
+ '.soul-trail-layer[data-layer="7"]{animation-name:soul-trail-run-7;opacity:.08}',
418
+ '@keyframes soul-trail-run-0{from{stroke-dashoffset:0px}to{stroke-dashoffset:-100px}}',
419
+ '@keyframes soul-trail-run-1{from{stroke-dashoffset:2.2px}to{stroke-dashoffset:-97.8px}}',
420
+ '@keyframes soul-trail-run-2{from{stroke-dashoffset:4.4px}to{stroke-dashoffset:-95.6px}}',
421
+ '@keyframes soul-trail-run-3{from{stroke-dashoffset:6.6px}to{stroke-dashoffset:-93.4px}}',
422
+ '@keyframes soul-trail-run-4{from{stroke-dashoffset:8.8px}to{stroke-dashoffset:-91.2px}}',
423
+ '@keyframes soul-trail-run-5{from{stroke-dashoffset:11px}to{stroke-dashoffset:-89px}}',
424
+ '@keyframes soul-trail-run-6{from{stroke-dashoffset:13.2px}to{stroke-dashoffset:-86.8px}}',
425
+ '@keyframes soul-trail-run-7{from{stroke-dashoffset:15.4px}to{stroke-dashoffset:-84.6px}}',
426
+ '[data-soul-trail-speed="slow"] .soul-trail-layer{animation-duration:4.8s}',
427
+ '[data-soul-trail-speed="fast"] .soul-trail-layer{animation-duration:2.4s}',
428
+ '[data-soul-trail-width="thin"] .soul-trail-layer{stroke-width:1.5}',
429
+ '[data-soul-trail-width="thick"] .soul-trail-layer{stroke-width:4}',
430
+ '[data-soul-trail="off"] .soul-trail-svg{display:none}',
431
+ // 光轨开启时隐藏宿主输入框自身的细边框(保留 1px 占位,避免布局跳动),
432
+ // 使边界上只存在光轨一条线
433
+ '[data-composer-card][data-soul-trail="on"]{--dsw-elevation-stroke-color:transparent}',
434
+ // 设置页:颜色控件与实时示例
435
+ '.soul-trail-colors{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
436
+ '.soul-trail-swatch{width:26px;height:26px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer}',
437
+ '.soul-trail-swatch[data-active="true"]{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
438
+ '.soul-trail-picker{width:34px;height:26px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:none;cursor:pointer}',
439
+ '.soul-field .soul-trail-colors input.soul-trail-hex{width:104px;padding:5px 8px;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:12px;line-height:1.4;font-family:Consolas,Menlo,monospace;box-sizing:border-box}',
440
+ '.soul-trail-preview{position:relative;margin-top:6px;padding:16px;border:1px solid var(--dsw-alias-border-l2);border-radius:22px;background:var(--dsw-alias-bg-layer-1)}',
441
+ '.soul-trail-preview[data-soul-trail="on"]{border-color:transparent}',
442
+ '.soul-trail-preview-text{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
443
+ '@media (prefers-reduced-motion:reduce){.soul-trail-layer{animation:none}}'
252
444
  ].join('')
253
445
 
254
446
  const tagId = 'dsh-soul/styles.css'
@@ -275,7 +467,16 @@ window.__ModuleLoader__.load({
275
467
  const zh = {
276
468
  'nav.label': '个性化',
277
469
  'settings.title': '个性化设置',
470
+ 'accordion.filled': '项已填写',
471
+ 'accordion.empty': '未填写',
472
+ 'accordion.personas': '个预设',
473
+ 'accordion.on': '已开启',
474
+ 'accordion.off': '未开启',
278
475
  'toggle.enabled': '启用个性化设置',
476
+ 'quick.enable': '开启个性化',
477
+ 'quick.disable': '关闭个性化',
478
+ 'quick.statusEnabled': '个性化已启用',
479
+ 'quick.statusDisabled': '个性化已关闭',
279
480
  'group.aboutYou': '关于你',
280
481
  'field.nickname': '用户昵称',
281
482
  'field.nicknamePlaceholder': '输入你的昵称,回复时会称呼你',
@@ -295,6 +496,13 @@ window.__ModuleLoader__.load({
295
496
  'field.instructions': '自定义指令',
296
497
  'hint.instructions': '补充角色、习惯等个性化要求。建议不要重复设置回复风格和语调类似的话术,以免与上方选项冲突。',
297
498
  'field.instructionsPlaceholder': '输入自定义指令,可以是其他行为、回复风格和语调等偏好设置',
499
+ 'group.personas': '人设预设',
500
+ 'personas.save': '保存当前为预设',
501
+ 'personas.savePlaceholder': '预设名称(1-30 字符)',
502
+ 'personas.use': '使用',
503
+ 'personas.delete': '删除',
504
+ 'personas.confirmDelete': '确定删除预设「{name}」?',
505
+ 'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
298
506
  'style.professional': '专业严谨',
299
507
  'style.casual': '轻松自然',
300
508
  'style.humorous': '幽默风趣',
@@ -306,6 +514,9 @@ window.__ModuleLoader__.load({
306
514
  'trait.emoji.default': '默认',
307
515
  'trait.emoji.more': '增强(使用较多表情符号)',
308
516
  'trait.emoji.less': '减弱(尽量减少使用表情符号)',
517
+ 'group.tool': 'Agent 工具',
518
+ 'field.toolConfirm': '人设变更需确认',
519
+ 'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
309
520
  'button.save': '保存设置',
310
521
  'button.saving': '保存中...',
311
522
  'button.reset': '重置默认',
@@ -314,17 +525,49 @@ window.__ModuleLoader__.load({
314
525
  'toast.saved': '✅ 设置已保存',
315
526
  'toast.noChanges': '✅ 配置无变化',
316
527
  'toast.reset': '✅ 已重置为默认值',
528
+ 'toast.personaSaved': '✅ 预设已保存',
529
+ 'toast.personaUsed': '✅ 预设已应用',
530
+ 'toast.personaUnchanged': 'ℹ 预设与当前配置一致',
531
+ 'toast.personaDeleted': '✅ 预设已删除',
317
532
  'prompt.view': '查看当前生效提示词',
318
533
  'prompt.hide': '收起提示词',
534
+ 'prompt.title': '当前生效提示词',
535
+ 'prompt.summaryEnabled': '已启用',
536
+ 'prompt.summaryDisabled': '已禁用',
537
+ 'prompt.summaryChars': '{n} 字符',
319
538
  'prompt.loading': '提示词加载中...',
320
539
  'prompt.empty': '(个性化已禁用,提示词为空)',
321
- 'prompt.chars': '当前字符数:{n}'
540
+ 'prompt.chars': '当前字符数:{n}',
541
+ 'toast.saveFailed': '❌ 保存失败,请检查配置',
542
+ 'trail.title': '输入框光轨',
543
+ 'trail.enable': '启用输入框光轨',
544
+ 'trail.color': '光轨颜色',
545
+ 'trail.speed': '流动速度',
546
+ 'trail.speed.slow': '慢',
547
+ 'trail.speed.normal': '中',
548
+ 'trail.speed.fast': '快',
549
+ 'trail.width': '光轨粗细',
550
+ 'trail.width.thin': '细',
551
+ 'trail.width.normal': '中',
552
+ 'trail.width.thick': '粗',
553
+ 'trail.preview': '效果示例',
554
+ 'trail.previewText': 'Agent 回复时,输入框边框会出现流动的光轨',
555
+ 'hint.trail': 'Agent 回复期间,输入框边框显示沿边流动的光轨。点击色块、使用取色器,或直接填写十六进制色值(如 #679EFE)。'
322
556
  }
323
557
 
324
558
  const en = {
325
559
  'nav.label': 'Personalization',
326
560
  'settings.title': 'Personalization Settings',
561
+ 'accordion.filled': 'fields filled',
562
+ 'accordion.empty': 'No fields set',
563
+ 'accordion.personas': 'personas',
564
+ 'accordion.on': 'enabled',
565
+ 'accordion.off': 'disabled',
327
566
  'toggle.enabled': 'Enable personalization',
567
+ 'quick.enable': 'Enable personalization',
568
+ 'quick.disable': 'Disable personalization',
569
+ 'quick.statusEnabled': 'Personalization enabled',
570
+ 'quick.statusDisabled': 'Personalization disabled',
328
571
  'group.aboutYou': 'About you',
329
572
  'field.nickname': 'Nickname',
330
573
  'field.nicknamePlaceholder': 'Your nickname — the agent will address you by it',
@@ -344,6 +587,13 @@ window.__ModuleLoader__.load({
344
587
  'field.instructions': 'Custom instructions',
345
588
  'hint.instructions': 'Extra persona or habit requirements. Avoid repeating wording similar to the style & tone option above to prevent conflicts.',
346
589
  'field.instructionsPlaceholder': 'Custom instructions — other behaviors, style or tone preferences',
590
+ 'group.personas': 'Personas',
591
+ 'personas.save': 'Save current as persona',
592
+ 'personas.savePlaceholder': 'Persona name (1-30 chars)',
593
+ 'personas.use': 'Use',
594
+ 'personas.delete': 'Delete',
595
+ 'personas.confirmDelete': 'Delete persona "{name}"?',
596
+ 'personas.empty': 'No personas yet — save the current config to switch with one click',
347
597
  'style.professional': 'Professional',
348
598
  'style.casual': 'Casual',
349
599
  'style.humorous': 'Humorous',
@@ -355,6 +605,9 @@ window.__ModuleLoader__.load({
355
605
  'trait.emoji.default': 'Default',
356
606
  'trait.emoji.more': 'More (frequent emoji usage)',
357
607
  'trait.emoji.less': 'Less (minimal emoji usage)',
608
+ 'group.tool': 'Agent tools',
609
+ 'field.toolConfirm': 'Require confirmation for persona changes',
610
+ '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
611
  'button.save': 'Save Settings',
359
612
  'button.saving': 'Saving...',
360
613
  'button.reset': 'Reset to Default',
@@ -363,11 +616,34 @@ window.__ModuleLoader__.load({
363
616
  'toast.saved': '✅ Settings saved',
364
617
  'toast.noChanges': '✅ No changes to save',
365
618
  'toast.reset': '✅ Reset to defaults',
619
+ 'toast.personaSaved': '✅ Persona saved',
620
+ 'toast.personaUsed': '✅ Persona applied',
621
+ 'toast.personaUnchanged': 'ℹ Persona matches current config',
622
+ 'toast.personaDeleted': '✅ Persona deleted',
366
623
  'prompt.view': 'View the active system prompt',
367
624
  'prompt.hide': 'Hide the prompt',
625
+ 'prompt.title': 'Active system prompt',
626
+ 'prompt.summaryEnabled': 'Enabled',
627
+ 'prompt.summaryDisabled': 'Disabled',
628
+ 'prompt.summaryChars': '{n} chars',
368
629
  'prompt.loading': 'Loading prompt...',
369
630
  'prompt.empty': '(personalization disabled — the prompt is empty)',
370
- 'prompt.chars': 'Current character count: {n}'
631
+ 'prompt.chars': 'Current character count: {n}',
632
+ 'toast.saveFailed': '❌ Save failed — please check the config',
633
+ 'trail.title': 'Composer light trail',
634
+ 'trail.enable': 'Enable composer light trail',
635
+ 'trail.color': 'Trail color',
636
+ 'trail.speed': 'Flow speed',
637
+ 'trail.speed.slow': 'Slow',
638
+ 'trail.speed.normal': 'Normal',
639
+ 'trail.speed.fast': 'Fast',
640
+ 'trail.width': 'Trail thickness',
641
+ 'trail.width.thin': 'Thin',
642
+ 'trail.width.normal': 'Normal',
643
+ 'trail.width.thick': 'Thick',
644
+ 'trail.preview': 'Preview',
645
+ 'trail.previewText': 'While the agent is replying, a light trail runs along the composer border',
646
+ 'hint.trail': 'While the agent is replying, a light trail runs along the composer border. Pick a swatch, use the color picker, or type a hex value (e.g. #679EFE).'
371
647
  }
372
648
 
373
649
  // 图标替换匹配的导航文案(词典中 nav.label 的全部语言取值)
@@ -401,7 +677,7 @@ window.__ModuleLoader__.load({
401
677
  ]
402
678
 
403
679
  // 表单字段与 store 字段的一一对应(dirty 检测与保存载荷共用)
404
- const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions']
680
+ const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
405
681
 
406
682
  // 提示词小图标:hover 展示说明文字
407
683
  function SoulHint(props) {
@@ -425,12 +701,59 @@ window.__ModuleLoader__.load({
425
701
  )
426
702
  }
427
703
 
704
+ // 可折叠设置面板:同一时间只展开一个区域,并持久化用户最后的选择。
705
+ function SoulAccordion(props) {
706
+ const { id, title, summary, open, onToggle, children } = props
707
+ return e('section', { className: `soul-accordion${open ? ' soul-accordion-open' : ''}` },
708
+ e('button', {
709
+ type: 'button',
710
+ className: 'soul-accordion-trigger',
711
+ 'aria-expanded': open,
712
+ 'aria-controls': `${id}-body`,
713
+ onClick: onToggle
714
+ },
715
+ e('span', { className: 'soul-accordion-title' }, title),
716
+ e('span', { className: 'soul-accordion-summary' }, summary),
717
+ e('span', { className: 'soul-accordion-icon', 'aria-hidden': true }, open ? '−' : '+')
718
+ ),
719
+ open && e('div', { id: `${id}-body`, className: 'soul-accordion-body' }, children)
720
+ )
721
+ }
722
+
723
+ // 输入框工具栏快捷开关:复用全局 soulController,和设置页保持同一份状态。
724
+ function SoulQuickToggle(props) {
725
+ const { useSoulController, controller } = props
726
+ const t = typeof props.t === 'function' ? props.t : FALLBACK_T
727
+ const state = useSoulController((state) => state)
728
+ const enabled = state.enabled === true
729
+ const busy = state.saving === true
730
+
731
+ const toggle = async () => {
732
+ if (busy) return
733
+ await controller.saveConfig({ enabled: !enabled })
734
+ }
735
+
736
+ return e('button', {
737
+ type: 'button',
738
+ className: 'soul-quick-toggle',
739
+ 'data-enabled': enabled ? 'true' : 'false',
740
+ 'aria-pressed': enabled,
741
+ 'aria-label': enabled ? t('quick.disable') : t('quick.enable'),
742
+ title: enabled ? t('quick.disable') : t('quick.enable'),
743
+ disabled: busy,
744
+ onClick: toggle
745
+ },
746
+ e('span', { className: 'soul-quick-toggle-dot', 'aria-hidden': true }),
747
+ enabled ? t('quick.statusEnabled') : t('quick.statusDisabled')
748
+ )
749
+ }
750
+
428
751
  function SoulSettings(props) {
429
752
  const { useSoulController, controller } = props
430
753
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
431
754
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
432
755
  const state = useSoulController((state) => state)
433
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, loading, saving, error, lastChanged } = state
756
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, loading, saving, error } = state
434
757
 
435
758
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
436
759
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
@@ -441,12 +764,33 @@ window.__ModuleLoader__.load({
441
764
  const [localEmoji, setLocalEmoji] = React.useState(emoji)
442
765
  const [localLanguage, setLocalLanguage] = React.useState(language || 'zh')
443
766
  const [localInstructions, setLocalInstructions] = React.useState(customInstructions)
444
- const [showSuccess, setShowSuccess] = React.useState(false)
445
- const [showResetSuccess, setShowResetSuccess] = React.useState(false)
767
+ const [localToolConfirm, setLocalToolConfirm] = React.useState(requireToolConfirmation === true)
768
+ const [localTrailEnabled, setLocalTrailEnabled] = React.useState(trailEnabled === true)
769
+ const [localTrailColor, setLocalTrailColor] = React.useState(trailColor || TRAIL_COLOR_FALLBACK)
770
+ const [localTrailSpeed, setLocalTrailSpeed] = React.useState(trailSpeed || 'slow')
771
+ const [localTrailWidth, setLocalTrailWidth] = React.useState(trailWidth || 'thin')
772
+ const [personaName, setPersonaName] = React.useState('')
773
+ const [toast, setToast] = React.useState(null)
446
774
  const [showPrompt, setShowPrompt] = React.useState(false)
447
775
  const [promptText, setPromptText] = React.useState('')
448
776
  const [promptLoading, setPromptLoading] = React.useState(false)
449
777
  const [promptError, setPromptError] = React.useState(null)
778
+ const [openSection, setOpenSection] = React.useState(() => {
779
+ try {
780
+ return globalThis.localStorage?.getItem('dsh-soul:open-section') || 'about'
781
+ } catch {
782
+ return 'about'
783
+ }
784
+ })
785
+
786
+ React.useEffect(() => {
787
+ try {
788
+ if (openSection) globalThis.localStorage?.setItem('dsh-soul:open-section', openSection)
789
+ else globalThis.localStorage?.removeItem('dsh-soul:open-section')
790
+ } catch {
791
+ // localStorage 不可用时不影响设置页使用
792
+ }
793
+ }, [openSection])
450
794
 
451
795
  // 同步状态
452
796
  React.useEffect(() => {
@@ -459,27 +803,24 @@ window.__ModuleLoader__.load({
459
803
  setLocalEmoji(emoji)
460
804
  setLocalLanguage(language || 'zh')
461
805
  setLocalInstructions(customInstructions)
462
- }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions])
463
-
464
- // 保存成功后显示提示,2秒后自动消失
806
+ setLocalToolConfirm(requireToolConfirmation === true)
807
+ setLocalTrailEnabled(trailEnabled === true)
808
+ setLocalTrailColor(trailColor || TRAIL_COLOR_FALLBACK)
809
+ setLocalTrailSpeed(trailSpeed || 'slow')
810
+ setLocalTrailWidth(trailWidth || 'thin')
811
+ }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
812
+
813
+ // toast 自动消失
465
814
  React.useEffect(() => {
466
- if (showSuccess) {
467
- const timer = setTimeout(() => setShowSuccess(false), 2000)
815
+ if (toast) {
816
+ const timer = setTimeout(() => setToast(null), 2000)
468
817
  return () => clearTimeout(timer)
469
818
  }
470
- }, [showSuccess])
471
-
472
- // 重置成功后显示提示,2秒后自动消失
473
- React.useEffect(() => {
474
- if (showResetSuccess) {
475
- const timer = setTimeout(() => setShowResetSuccess(false), 2000)
476
- return () => clearTimeout(timer)
477
- }
478
- }, [showResetSuccess])
819
+ }, [toast])
479
820
 
480
821
  // 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 }
822
+ const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
823
+ const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm, trailEnabled: localTrailEnabled, trailColor: localTrailColor, trailSpeed: localTrailSpeed, trailWidth: localTrailWidth }
483
824
  const dirty = FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
484
825
 
485
826
  const loadPrompt = async () => {
@@ -501,25 +842,124 @@ window.__ModuleLoader__.load({
501
842
  if (next) await loadPrompt()
502
843
  }
503
844
 
845
+ const loadPersonas = async () => {
846
+ try {
847
+ const payload = await controller.fetchPersonas()
848
+ controller.store.update((s) => {
849
+ s.personas = payload.personas
850
+ s.activePersona = payload.activeName
851
+ })
852
+ } catch {
853
+ // 预设列表加载失败不打断主配置界面
854
+ }
855
+ }
856
+
857
+ // 设置页打开后重新读取宿主配置,补齐 /soul 命令等外部写入路径。
858
+ // 表单 dirty 或正在保存时暂停,避免远端刷新覆盖用户未保存的输入。
859
+ React.useEffect(() => {
860
+ const refresh = () => {
861
+ if (dirty || saving) return
862
+ void controller.loadConfig()
863
+ void loadPersonas()
864
+ }
865
+ const onVisibilityChange = () => {
866
+ if (document.visibilityState === 'visible') refresh()
867
+ }
868
+ refresh()
869
+ window.addEventListener('focus', refresh)
870
+ document.addEventListener('visibilitychange', onVisibilityChange)
871
+ const timer = setInterval(refresh, 2000)
872
+ return () => {
873
+ window.removeEventListener('focus', refresh)
874
+ document.removeEventListener('visibilitychange', onVisibilityChange)
875
+ clearInterval(timer)
876
+ }
877
+ }, [controller, dirty, saving])
878
+
504
879
  const handleSave = async () => {
505
- await controller.saveConfig(localMap)
506
- setShowSuccess(true)
880
+ const payload = await controller.saveConfig(localMap)
881
+ // saveConfig 失败时内部已记录 error 并返回 undefined:给出失败提示,而非误报「无变化」
882
+ if (!payload) {
883
+ setToast({ text: t('toast.saveFailed'), kind: 'error' })
884
+ return
885
+ }
886
+ setToast({
887
+ text: Array.isArray(payload.changed) && payload.changed.length > 0 ? t('toast.saved') : t('toast.noChanges'),
888
+ kind: 'success'
889
+ })
507
890
  if (showPrompt) await loadPrompt()
508
891
  }
509
892
 
510
893
  const handleReset = async () => {
511
894
  await controller.resetConfig()
512
- setShowResetSuccess(true)
895
+ setToast({ text: t('toast.reset'), kind: 'success' })
513
896
  if (showPrompt) await loadPrompt()
514
897
  }
515
898
 
899
+ const handleSavePersona = async () => {
900
+ const name = personaName.trim()
901
+ if (!name) return
902
+ try {
903
+ await controller.savePersona(name)
904
+ setPersonaName('')
905
+ await loadPersonas()
906
+ setToast({ text: t('toast.personaSaved'), kind: 'success' })
907
+ } catch (err) {
908
+ setToast({ text: messageOf(err), kind: 'error' })
909
+ }
910
+ }
911
+
912
+ const handleUsePersona = async (name) => {
913
+ try {
914
+ const payload = await controller.usePersona(name)
915
+ await loadPersonas()
916
+ setToast({ text: payload && payload.unchanged ? t('toast.personaUnchanged') : t('toast.personaUsed'), kind: 'success' })
917
+ if (showPrompt) await loadPrompt()
918
+ } catch (err) {
919
+ setToast({ text: messageOf(err), kind: 'error' })
920
+ }
921
+ }
922
+
923
+ const handleDeletePersona = async (name) => {
924
+ if (typeof globalThis.confirm === 'function' && !globalThis.confirm(t('personas.confirmDelete', { name }))) return
925
+ try {
926
+ await controller.deletePersona(name)
927
+ await loadPersonas()
928
+ setToast({ text: t('toast.personaDeleted'), kind: 'success' })
929
+ } catch (err) {
930
+ setToast({ text: messageOf(err), kind: 'error' })
931
+ }
932
+ }
933
+
516
934
  const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
517
935
  const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
518
936
  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')
937
+ const personaNames = personas ? Object.keys(personas).sort() : null
938
+ const aboutFilled = [localNickname, localOccupation, localBio].filter(Boolean).length
939
+ const aboutSummary = aboutFilled > 0 ? `${aboutFilled}/3 ${t('accordion.filled')}` : t('accordion.empty')
940
+ const traitsSummary = t(`style.${localStyle}`)
941
+ const personasSummary = personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`
942
+ const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
943
+ const promptSummary = promptText.length > 0
944
+ ? t('prompt.summaryChars', { n: String(promptText.length) })
945
+ : (enabled ? t('prompt.summaryEnabled') : t('prompt.summaryDisabled'))
946
+ const toggleSection = (section) => () => {
947
+ setOpenSection(current => current === section ? null : section)
948
+ }
949
+
950
+ const personaRowMeta = (entry) => {
951
+ const parts = []
952
+ if (entry && STYLE_VALUES.includes(entry.style)) parts.push(t(`style.${entry.style}`))
953
+ else if (entry && entry.style) parts.push(entry.style)
954
+ if (entry && entry.nickname) parts.push(entry.nickname)
955
+ return parts.join(' · ')
956
+ }
520
957
 
521
958
  return e('div', { className: 'soul-section' },
522
- e('h3', null, t('settings.title')),
959
+ e('div', { className: 'soul-title-row' },
960
+ e('h3', null, t('settings.title')),
961
+ e('span', { className: 'soul-version' }, `v${VERSION}`)
962
+ ),
523
963
  e('div', { className: 'soul-toggle' },
524
964
  e('input', {
525
965
  type: 'checkbox',
@@ -531,117 +971,272 @@ window.__ModuleLoader__.load({
531
971
  ),
532
972
 
533
973
  localEnabled && e(Fragment, null,
534
- e('div', { className: 'soul-group-title' }, t('group.aboutYou')),
535
- e('div', { className: 'soul-field' },
536
- e('label', { htmlFor: 'soul-nickname' }, t('field.nickname')),
537
- e('input', {
538
- type: 'text',
539
- id: 'soul-nickname',
540
- value: localNickname,
541
- onChange: (ev) => setLocalNickname(ev.target.value),
542
- placeholder: t('field.nicknamePlaceholder')
543
- })
544
- ),
545
- e('div', { className: 'soul-field' },
546
- e('label', { htmlFor: 'soul-occupation' }, t('field.occupation')),
547
- e('input', {
548
- type: 'text',
549
- id: 'soul-occupation',
550
- value: localOccupation,
551
- onChange: (ev) => setLocalOccupation(ev.target.value),
552
- placeholder: t('field.occupationPlaceholder')
553
- })
554
- ),
555
- e('div', { className: 'soul-field' },
556
- e('label', { htmlFor: 'soul-bio' }, t('field.bio')),
557
- e('textarea', {
558
- id: 'soul-bio',
559
- className: 'soul-textarea-sm',
560
- value: localBio,
561
- onChange: (ev) => setLocalBio(ev.target.value),
562
- placeholder: t('field.bioPlaceholder')
563
- })
564
- ),
565
-
566
- e('div', { className: 'soul-group-title' }, t('group.traits')),
567
- e('div', { className: 'soul-field' },
568
- e('label', { htmlFor: 'soul-style' },
569
- t('field.style'),
570
- e(SoulHint, { text: t('hint.style') })
974
+ e(SoulAccordion, {
975
+ id: 'soul-about',
976
+ title: t('group.aboutYou'),
977
+ summary: aboutSummary,
978
+ open: openSection === 'about',
979
+ onToggle: toggleSection('about')
980
+ },
981
+ e('div', { className: 'soul-field' },
982
+ e('label', { htmlFor: 'soul-nickname' }, t('field.nickname')),
983
+ e('input', {
984
+ type: 'text',
985
+ id: 'soul-nickname',
986
+ value: localNickname,
987
+ onChange: (ev) => setLocalNickname(ev.target.value),
988
+ placeholder: t('field.nicknamePlaceholder')
989
+ })
571
990
  ),
572
- e('select', {
573
- id: 'soul-style',
574
- value: localStyle,
575
- onChange: (ev) => setLocalStyle(ev.target.value)
576
- },
577
- ...styleOptions.map(opt =>
578
- e('option', { key: opt.value, value: opt.value }, opt.label)
579
- )
991
+ e('div', { className: 'soul-field' },
992
+ e('label', { htmlFor: 'soul-occupation' }, t('field.occupation')),
993
+ e('input', {
994
+ type: 'text',
995
+ id: 'soul-occupation',
996
+ value: localOccupation,
997
+ onChange: (ev) => setLocalOccupation(ev.target.value),
998
+ placeholder: t('field.occupationPlaceholder')
999
+ })
1000
+ ),
1001
+ e('div', { className: 'soul-field' },
1002
+ e('label', { htmlFor: 'soul-bio' }, t('field.bio')),
1003
+ e('textarea', {
1004
+ id: 'soul-bio',
1005
+ className: 'soul-textarea-sm',
1006
+ value: localBio,
1007
+ onChange: (ev) => setLocalBio(ev.target.value),
1008
+ placeholder: t('field.bioPlaceholder')
1009
+ })
580
1010
  )
581
1011
  ),
582
1012
 
583
- e('div', { className: 'soul-field' },
584
- e('label', { htmlFor: 'soul-headingLists' },
585
- t('field.headingLists'),
586
- e(SoulHint, { text: t('hint.headingLists') })
1013
+ e(SoulAccordion, {
1014
+ id: 'soul-traits',
1015
+ title: t('group.traits'),
1016
+ summary: traitsSummary,
1017
+ open: openSection === 'traits',
1018
+ onToggle: toggleSection('traits')
1019
+ },
1020
+ e('div', { className: 'soul-field' },
1021
+ e('label', { htmlFor: 'soul-style' },
1022
+ t('field.style'),
1023
+ e(SoulHint, { text: t('hint.style') })
1024
+ ),
1025
+ e('select', {
1026
+ id: 'soul-style',
1027
+ value: localStyle,
1028
+ onChange: (ev) => setLocalStyle(ev.target.value)
1029
+ },
1030
+ ...styleOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1031
+ )
587
1032
  ),
588
- e('select', {
589
- id: 'soul-headingLists',
590
- value: localHeadingLists,
591
- onChange: (ev) => setLocalHeadingLists(ev.target.value)
592
- },
593
- ...headingListsOptions.map(opt =>
594
- e('option', { key: opt.value, value: opt.value }, opt.label)
1033
+ e('div', { className: 'soul-field' },
1034
+ e('label', { htmlFor: 'soul-headingLists' },
1035
+ t('field.headingLists'),
1036
+ e(SoulHint, { text: t('hint.headingLists') })
1037
+ ),
1038
+ e('select', {
1039
+ id: 'soul-headingLists',
1040
+ value: localHeadingLists,
1041
+ onChange: (ev) => setLocalHeadingLists(ev.target.value)
1042
+ },
1043
+ ...headingListsOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1044
+ )
1045
+ ),
1046
+ e('div', { className: 'soul-field' },
1047
+ e('label', { htmlFor: 'soul-emoji' },
1048
+ t('field.emoji'),
1049
+ e(SoulHint, { text: t('hint.emoji') })
1050
+ ),
1051
+ e('select', {
1052
+ id: 'soul-emoji',
1053
+ value: localEmoji,
1054
+ onChange: (ev) => setLocalEmoji(ev.target.value)
1055
+ },
1056
+ ...emojiOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
595
1057
  )
596
- )
597
- ),
598
-
599
- e('div', { className: 'soul-field' },
600
- e('label', { htmlFor: 'soul-emoji' },
601
- t('field.emoji'),
602
- e(SoulHint, { text: t('hint.emoji') })
603
1058
  ),
604
- e('select', {
605
- id: 'soul-emoji',
606
- value: localEmoji,
607
- onChange: (ev) => setLocalEmoji(ev.target.value)
608
- },
609
- ...emojiOptions.map(opt =>
610
- e('option', { key: opt.value, value: opt.value }, opt.label)
1059
+ e('div', { className: 'soul-field' },
1060
+ e('label', { htmlFor: 'soul-language' },
1061
+ t('field.language'),
1062
+ e(SoulHint, { text: t('hint.language') })
1063
+ ),
1064
+ e('select', {
1065
+ id: 'soul-language',
1066
+ value: localLanguage,
1067
+ onChange: (ev) => setLocalLanguage(ev.target.value)
1068
+ },
1069
+ ...LANGUAGE_OPTIONS.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
611
1070
  )
1071
+ ),
1072
+ e('div', { className: 'soul-field' },
1073
+ e('label', { htmlFor: 'soul-instructions' },
1074
+ t('field.instructions'),
1075
+ e(SoulHint, { text: t('hint.instructions') })
1076
+ ),
1077
+ e('textarea', {
1078
+ id: 'soul-instructions',
1079
+ value: localInstructions,
1080
+ onChange: (ev) => setLocalInstructions(ev.target.value),
1081
+ placeholder: t('field.instructionsPlaceholder')
1082
+ })
612
1083
  )
613
1084
  ),
614
1085
 
615
- e('div', { className: 'soul-field' },
616
- e('label', { htmlFor: 'soul-language' },
617
- t('field.language'),
618
- e(SoulHint, { text: t('hint.language') })
1086
+ e(SoulAccordion, {
1087
+ id: 'soul-personas',
1088
+ title: t('group.personas'),
1089
+ summary: personasSummary,
1090
+ open: openSection === 'personas',
1091
+ onToggle: toggleSection('personas')
1092
+ },
1093
+ e('div', { className: 'soul-field' },
1094
+ personaNames === null
1095
+ ? e('div', { className: 'soul-status' }, t('status.loading'))
1096
+ : (personaNames.length === 0
1097
+ ? e('div', { className: 'soul-status' }, t('personas.empty'))
1098
+ : e(Fragment, null,
1099
+ ...personaNames.map((name) => {
1100
+ const entry = personas[name] || {}
1101
+ return e('div', { className: 'soul-persona-row', key: name },
1102
+ e('span', { className: 'soul-persona-name' }, name === activePersona ? `★ ${name}` : name),
1103
+ e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
1104
+ e('span', { className: 'soul-persona-actions' },
1105
+ e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
1106
+ e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
1107
+ )
1108
+ )
1109
+ })
1110
+ ))
619
1111
  ),
620
- e('select', {
621
- id: 'soul-language',
622
- value: localLanguage,
623
- onChange: (ev) => setLocalLanguage(ev.target.value)
624
- },
625
- ...LANGUAGE_OPTIONS.map(opt =>
626
- e('option', { key: opt.value, value: opt.value }, opt.label)
627
- )
1112
+ e('div', { className: 'soul-field soul-persona-save' },
1113
+ e('input', {
1114
+ type: 'text',
1115
+ value: personaName,
1116
+ maxLength: 30,
1117
+ onChange: (ev) => setPersonaName(ev.target.value),
1118
+ placeholder: t('personas.savePlaceholder')
1119
+ }),
1120
+ e(ui.Button, {
1121
+ onClick: handleSavePersona,
1122
+ disabled: saving || !personaName.trim()
1123
+ }, t('personas.save'))
628
1124
  )
629
1125
  ),
630
1126
 
631
- e('div', { className: 'soul-field' },
632
- e('label', { htmlFor: 'soul-instructions' },
633
- t('field.instructions'),
634
- e(SoulHint, { text: t('hint.instructions') })
1127
+ e(SoulAccordion, {
1128
+ id: 'soul-tools',
1129
+ title: t('group.tool'),
1130
+ summary: toolSummary,
1131
+ open: openSection === 'tools',
1132
+ onToggle: toggleSection('tools')
1133
+ },
1134
+ e('div', { className: 'soul-toggle' },
1135
+ e('input', {
1136
+ type: 'checkbox',
1137
+ id: 'soul-toolConfirm',
1138
+ checked: localToolConfirm,
1139
+ onChange: (ev) => setLocalToolConfirm(ev.target.checked)
1140
+ }),
1141
+ e('label', { htmlFor: 'soul-toolConfirm' }, t('field.toolConfirm')),
1142
+ e(SoulHint, { text: t('hint.toolConfirm') })
1143
+ ),
1144
+
1145
+ e('div', { className: 'soul-group-title' }, t('trail.title')),
1146
+ e('div', { className: 'soul-toggle' },
1147
+ e('input', {
1148
+ type: 'checkbox',
1149
+ id: 'soul-trailEnabled',
1150
+ checked: localTrailEnabled,
1151
+ onChange: (ev) => setLocalTrailEnabled(ev.target.checked)
1152
+ }),
1153
+ e('label', { htmlFor: 'soul-trailEnabled' }, t('trail.enable')),
1154
+ e(SoulHint, { text: t('hint.trail') })
635
1155
  ),
636
- e('textarea', {
637
- id: 'soul-instructions',
638
- value: localInstructions,
639
- onChange: (ev) => setLocalInstructions(ev.target.value),
640
- placeholder: t('field.instructionsPlaceholder')
641
- })
1156
+
1157
+ localTrailEnabled && e(Fragment, null,
1158
+ e('div', { className: 'soul-field' },
1159
+ e('label', { htmlFor: 'soul-trailColor' }, t('trail.color')),
1160
+ e('div', { className: 'soul-trail-colors' },
1161
+ ...TRAIL_PRESET_COLORS.map((value) => e('button', {
1162
+ key: value,
1163
+ type: 'button',
1164
+ className: 'soul-trail-swatch',
1165
+ 'data-active': safeTrailColor(localTrailColor) === value ? 'true' : 'false',
1166
+ style: { background: value },
1167
+ 'aria-label': value,
1168
+ title: value,
1169
+ onClick: () => setLocalTrailColor(value)
1170
+ })),
1171
+ e('input', {
1172
+ type: 'color',
1173
+ id: 'soul-trailColor',
1174
+ className: 'soul-trail-picker',
1175
+ value: safeTrailColor(localTrailColor),
1176
+ onChange: (ev) => setLocalTrailColor(safeTrailColor(ev.target.value))
1177
+ }),
1178
+ e('input', {
1179
+ type: 'text',
1180
+ className: 'soul-trail-hex',
1181
+ value: localTrailColor,
1182
+ maxLength: 7,
1183
+ spellCheck: false,
1184
+ onChange: (ev) => setLocalTrailColor(ev.target.value),
1185
+ placeholder: TRAIL_COLOR_FALLBACK
1186
+ })
1187
+ )
1188
+ ),
1189
+ e('div', { className: 'soul-field' },
1190
+ e('label', { htmlFor: 'soul-trailSpeed' }, t('trail.speed')),
1191
+ e('select', {
1192
+ id: 'soul-trailSpeed',
1193
+ value: localTrailSpeed,
1194
+ onChange: (ev) => setLocalTrailSpeed(ev.target.value)
1195
+ },
1196
+ ...TRAIL_SPEED_VALUES.map((value) => e('option', { key: value, value }, t(`trail.speed.${value}`)))
1197
+ )
1198
+ ),
1199
+ e('div', { className: 'soul-field' },
1200
+ e('label', { htmlFor: 'soul-trailWidth' }, t('trail.width')),
1201
+ e('select', {
1202
+ id: 'soul-trailWidth',
1203
+ value: localTrailWidth,
1204
+ onChange: (ev) => setLocalTrailWidth(ev.target.value)
1205
+ },
1206
+ ...TRAIL_WIDTH_VALUES.map((value) => e('option', { key: value, value }, t(`trail.width.${value}`)))
1207
+ )
1208
+ ),
1209
+ e('div', { className: 'soul-field' },
1210
+ e('label', null, t('trail.preview')),
1211
+ e(SoulTrailPreview, {
1212
+ enabled: localTrailEnabled,
1213
+ color: localTrailColor,
1214
+ speed: localTrailSpeed,
1215
+ width: localTrailWidth,
1216
+ t
1217
+ })
1218
+ )
1219
+ )
642
1220
  )
643
1221
  ),
644
1222
 
1223
+ e(SoulAccordion, {
1224
+ id: 'soul-prompt',
1225
+ title: t('prompt.title'),
1226
+ summary: promptSummary,
1227
+ open: showPrompt,
1228
+ onToggle: togglePrompt
1229
+ },
1230
+ promptLoading
1231
+ ? e('div', { className: 'soul-status' }, t('prompt.loading'))
1232
+ : (promptError
1233
+ ? e('div', { className: 'soul-error' }, promptError)
1234
+ : e(Fragment, null,
1235
+ e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
1236
+ e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) }))
1237
+ ))
1238
+ ),
1239
+
645
1240
  error && e('div', { className: 'soul-error' }, error),
646
1241
 
647
1242
  e('div', { className: 'soul-buttons' },
@@ -658,42 +1253,95 @@ window.__ModuleLoader__.load({
658
1253
 
659
1254
  dirty && !saving && e('div', { className: 'soul-status soul-dirty' }, t('status.unsaved')),
660
1255
 
661
- e('div', { className: 'soul-prompt-toggle' },
662
- e('button', {
663
- type: 'button',
664
- className: 'soul-prompt-link',
665
- onClick: togglePrompt
666
- }, showPrompt ? t('prompt.hide') : t('prompt.view'))
667
- ),
668
- showPrompt && e('div', { className: 'soul-prompt-panel' },
669
- promptLoading
670
- ? e('div', { className: 'soul-status' }, t('prompt.loading'))
671
- : (promptError
672
- ? e('div', { className: 'soul-error' }, promptError)
673
- : e(Fragment, null,
674
- e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
675
- e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) }))
676
- ))
677
- ),
678
-
679
- loading && e('div', { className: 'soul-status' }, t('status.loading')),
680
-
681
- showSuccess && e('div', {
682
- className: 'soul-toast soul-toast-success',
1256
+ toast && e('div', {
1257
+ className: `soul-toast ${toast.kind === 'error' ? 'soul-toast-error' : 'soul-toast-success'}`,
683
1258
  style: {
684
1259
  animation: 'fadeInOut 2s ease-in-out'
685
1260
  }
686
- }, savedToastText),
1261
+ }, toast.text)
1262
+ )
1263
+ }
687
1264
 
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'))
1265
+ // -------------------------------------------------------------------------
1266
+ // 输入框光轨:组件
1267
+ // -------------------------------------------------------------------------
1268
+
1269
+ // 设置页内的实时示例:与线上光轨共用同一渲染层与 CSS,
1270
+ // 因此「示例所见」即「回复时所得」;颜色/速度/粗细随表单实时联动。
1271
+ function SoulTrailPreview(props) {
1272
+ const t = typeof props.t === 'function' ? props.t : FALLBACK_T
1273
+ const hostRef = React.useRef(null)
1274
+
1275
+ React.useEffect(() => {
1276
+ const host = hostRef.current
1277
+ if (!host) return undefined
1278
+ return mountTrailRing(host)
1279
+ }, [])
1280
+
1281
+ return e('div', {
1282
+ ref: hostRef,
1283
+ className: 'soul-trail-preview',
1284
+ 'data-soul-trail': props.enabled ? 'on' : 'off',
1285
+ 'data-soul-trail-speed': props.speed || 'slow',
1286
+ 'data-soul-trail-width': props.width || 'thin',
1287
+ style: { '--soul-trail-color': safeTrailColor(props.color) }
1288
+ },
1289
+ e('span', { className: 'soul-trail-preview-text' }, t('trail.previewText'))
694
1290
  )
695
1291
  }
696
1292
 
1293
+ // 输入框光轨运行时:注册在 conversation.input.overlay(输入框卡片内部)。
1294
+ // 组件本身不渲染可见内容——它在最近的卡片上挂一层 SVG 环,
1295
+ // 并用会话运行态控制显隐。运行状态来自 DSH 标准 prop useSession
1296
+ // (SessionSnapshot.running,等价于 Agent 状态为 running)。
1297
+ function SoulTrail(props) {
1298
+ const useSession = props.useSession
1299
+ const useStore = props.useSoulController
1300
+ const running = typeof useSession === 'function'
1301
+ ? useSession((snapshot) => snapshot.running === true)
1302
+ : false
1303
+ const state = typeof useStore === 'function' ? useStore((snapshot) => snapshot) : null
1304
+ const anchorRef = React.useRef(null)
1305
+ const mountedRef = React.useRef(null)
1306
+
1307
+ React.useEffect(() => {
1308
+ const anchor = anchorRef.current
1309
+ // 组件位于卡片内的 overlay 锚点中,closest 定位可避免全局查询误命中
1310
+ const host = anchor && typeof anchor.closest === 'function'
1311
+ ? anchor.closest('[data-composer-card]')
1312
+ : null
1313
+ if (!host) return undefined
1314
+
1315
+ mountedRef.current = { host, unmount: mountTrailRing(host) }
1316
+ // 先置为隐藏:效果在首帧绘制后执行,先写 off 可避免挂载瞬间闪出一帧光轨
1317
+ host.setAttribute('data-soul-trail', 'off')
1318
+ return () => {
1319
+ const mounted = mountedRef.current
1320
+ mountedRef.current = null
1321
+ if (!mounted) return
1322
+ mounted.unmount()
1323
+ mounted.host.removeAttribute('data-soul-trail')
1324
+ mounted.host.removeAttribute('data-soul-trail-speed')
1325
+ mounted.host.removeAttribute('data-soul-trail-width')
1326
+ mounted.host.style.removeProperty('--soul-trail-color')
1327
+ }
1328
+ }, [])
1329
+
1330
+ // 显隐与外观同步:仅「Agent 回复中 + 光轨开关开启」时显示
1331
+ React.useEffect(() => {
1332
+ const mounted = mountedRef.current
1333
+ if (!mounted) return
1334
+ const { host } = mounted
1335
+ const enabled = state !== null && state.trailEnabled === true
1336
+ host.setAttribute('data-soul-trail', running && enabled ? 'on' : 'off')
1337
+ host.setAttribute('data-soul-trail-speed', (state && state.trailSpeed) || 'slow')
1338
+ host.setAttribute('data-soul-trail-width', (state && state.trailWidth) || 'thin')
1339
+ host.style.setProperty('--soul-trail-color', safeTrailColor(state && state.trailColor))
1340
+ }, [running, state])
1341
+
1342
+ return e('span', { ref: anchorRef, hidden: true, 'aria-hidden': true })
1343
+ }
1344
+
697
1345
  // -------------------------------------------------------------------------
698
1346
  // 插件装配
699
1347
  // -------------------------------------------------------------------------
@@ -708,6 +1356,11 @@ window.__ModuleLoader__.load({
708
1356
  }, 'dsh-soul: browser lifecycle')
709
1357
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-soul: dictionaries')
710
1358
 
1359
+ // 暗色适配说明:原生 <select> 的弹出选项列表由浏览器按 color-scheme 渲染。
1360
+ // 宿主主题呈现器会把 html 的 color-scheme 与 body[data-ds-dark-theme] 随主题
1361
+ // 投影到 document(见 dsh-client-ui-layout ThemePresenter),因此这里用纯 CSS
1362
+ // 属性选择器跟随即可,无需 JS 监听;选项行另用主题变量显式着色兜底。
1363
+
711
1364
  // 导航 label / 图标替换用的翻译函数(bind 返回的函数在调用时读取当前语言)
712
1365
  const navT = ctx.locale.bind(NS)
713
1366
 
@@ -793,6 +1446,34 @@ window.__ModuleLoader__.load({
793
1446
  // 注册图标替换效果
794
1447
  ctx.effect(() => registerSettingsNavIcon(), 'dsh-soul: settings navigation icon')
795
1448
 
1449
+ // 注册到输入框工具栏:宿主会把 input.left 放在「操作权限」控件之后。
1450
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
1451
+ name: 'conversation.input.left',
1452
+ id: 'soul-quick-toggle',
1453
+ order: 50,
1454
+ locale: NS,
1455
+ inject: () => ({
1456
+ hooks: {
1457
+ soulController: controller.store
1458
+ },
1459
+ controller
1460
+ })
1461
+ }, SoulQuickToggle))
1462
+
1463
+ // 输入框光轨:注册到输入框卡片内部的 overlay 槽位。
1464
+ // 组件不渲染可见内容——它按会话运行态在最近的输入框卡片上挂载/隐藏 SVG 环;
1465
+ // 同一份 soulController store 保证与设置页的开关、颜色实时一致。
1466
+ ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
1467
+ name: 'conversation.input.overlay',
1468
+ id: 'soul-trail',
1469
+ order: 50,
1470
+ inject: () => ({
1471
+ hooks: {
1472
+ soulController: controller.store
1473
+ }
1474
+ })
1475
+ }, SoulTrail))
1476
+
796
1477
  // 注册到设置页面(label 用 navT 实时解析;壳层在语言切换时会重新解析 label)
797
1478
  ctx.slots.inject('settings.section', () => ctx.slots.register({
798
1479
  name: 'settings.section',