dsh-soul 0.6.2 → 0.7.1
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/LICENSE +21 -0
- package/README.md +41 -23
- package/README_EN.md +45 -23
- package/assets/icon.svg +21 -0
- package/client/index.mjs +538 -101
- package/index.mjs +577 -93
- package/lib/config.mjs +56 -8
- package/lib/injection.mjs +2 -2
- package/lib/personas.mjs +184 -0
- package/lib/store.mjs +125 -0
- package/package.json +16 -4
- package/scripts/lib/skip-report.mjs +73 -0
- package/scripts/verify-client.mjs +700 -0
- package/scripts/verify-compat.mjs +58 -9
- package/scripts/verify-config.mjs +1308 -3
- package/scripts/verify-e2e-prompt.mjs +627 -0
- package/scripts/verify-live-prompt.mjs +445 -0
- package/scripts/verify-nav-icon.mjs +479 -0
- package/scripts/verify-store.mjs +310 -0
- package/scripts/verify-trail.mjs +21 -8
package/index.mjs
CHANGED
|
@@ -16,14 +16,15 @@
|
|
|
16
16
|
// - 注入系统提示词到 Agent
|
|
17
17
|
// - 配置写入统一走写队列,返回 changed(有无实际变更)与 promptChanged(是否需要刷新提示词并注入会话)
|
|
18
18
|
|
|
19
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
20
19
|
import { join } from 'node:path'
|
|
21
20
|
import { homedir } from 'node:os'
|
|
21
|
+
import { createRequire } from 'node:module'
|
|
22
22
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
23
|
import {
|
|
24
24
|
DEFAULT_CONFIG,
|
|
25
25
|
STYLE_VALUES,
|
|
26
26
|
TRAIT_VALUES,
|
|
27
|
+
REPLY_LENGTH_VALUES,
|
|
27
28
|
LANGUAGE_VALUES,
|
|
28
29
|
PERSONA_FIELDS,
|
|
29
30
|
PERSONA_NAME_MAX,
|
|
@@ -32,6 +33,8 @@ import {
|
|
|
32
33
|
sanitizePersonaName
|
|
33
34
|
} from './lib/config.mjs'
|
|
34
35
|
import { createInjectionSource } from './lib/injection.mjs'
|
|
36
|
+
import { isBuiltinPersona, mergePersonas, personaMatches, resolvePersona, declaredPersonaKeys } from './lib/personas.mjs'
|
|
37
|
+
import { configWriteRefusal, describeConfigFailure, moveAsideConfigFile, readConfigFile, writeConfigFile } from './lib/store.mjs'
|
|
35
38
|
|
|
36
39
|
const name = 'soul'
|
|
37
40
|
|
|
@@ -40,6 +43,19 @@ function configPath() {
|
|
|
40
43
|
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'soul-config.json')
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
// 版本号:唯一来源是 package.json。
|
|
47
|
+
//
|
|
48
|
+
// 刻意**不**在这里再写一个常量 —— 需要手工同步的版本号已经有三处
|
|
49
|
+
// (package.json / client 的 VERSION / RELEASE_NOTES.md),再加一处只会多一个漂移点。
|
|
50
|
+
// 读不到就返回 null:诊断端点不应该因为版本读不出来而失败。
|
|
51
|
+
function pluginVersion() {
|
|
52
|
+
try {
|
|
53
|
+
return createRequire(import.meta.url)('./package.json').version || null
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
// ==================== 提示词文案与构建器 ====================
|
|
44
60
|
|
|
45
61
|
// 提示词固定文案表:所有进入 system prompt / 会话注入 / 工具返回的文本,
|
|
@@ -69,6 +85,14 @@ const PROMPT_TEXT = {
|
|
|
69
85
|
more: '在回复中使用较多表情符号',
|
|
70
86
|
less: '尽量减少使用表情符号'
|
|
71
87
|
},
|
|
88
|
+
tables: {
|
|
89
|
+
more: '在呈现对比、多字段信息或结构化内容时优先使用表格',
|
|
90
|
+
less: '避免使用表格,改用列表或段落文本呈现'
|
|
91
|
+
},
|
|
92
|
+
replyLength: {
|
|
93
|
+
concise: '回答尽量简洁,直击要点,不展开无关内容',
|
|
94
|
+
detailed: '回答尽量详尽,充分展开背景、步骤与推理过程'
|
|
95
|
+
},
|
|
72
96
|
replyLanguage: {
|
|
73
97
|
zh: '必须使用简体中文回复',
|
|
74
98
|
en: '必须使用英语(English)回复'
|
|
@@ -116,6 +140,14 @@ const PROMPT_TEXT = {
|
|
|
116
140
|
more: 'Use emojis fairly often in replies',
|
|
117
141
|
less: 'Keep emoji usage to a minimum'
|
|
118
142
|
},
|
|
143
|
+
tables: {
|
|
144
|
+
more: 'Prefer tables when presenting comparisons, multi-field or structured information',
|
|
145
|
+
less: 'Avoid tables; use lists or paragraph text instead'
|
|
146
|
+
},
|
|
147
|
+
replyLength: {
|
|
148
|
+
concise: 'Keep answers concise: hit the key points and skip unrelated elaboration',
|
|
149
|
+
detailed: 'Give detailed answers: fully expand on background, steps and reasoning'
|
|
150
|
+
},
|
|
119
151
|
replyLanguage: {
|
|
120
152
|
zh: '必须使用简体中文回复',
|
|
121
153
|
en: 'You must reply in English.'
|
|
@@ -155,12 +187,16 @@ function buildUserProfile(config, T) {
|
|
|
155
187
|
return lines.join('\n')
|
|
156
188
|
}
|
|
157
189
|
|
|
158
|
-
// 行为块:回复风格和语调 +
|
|
190
|
+
// 行为块:回复风格和语调 + 特质(标题和列表 / 表情符号 / 表格)+ 回复长度 + 输出语言。
|
|
191
|
+
// 缺项(如 tables=default、replyLength=normal)在文案表中没有对应键,直接跳过、不产生文本,
|
|
192
|
+
// 因此默认配置编译出的提示词与新增这两个维度之前完全一致。
|
|
159
193
|
function buildBehavior(config, T) {
|
|
160
194
|
const parts = []
|
|
161
195
|
if (config.style && T.styles[config.style]) parts.push(`${T.styleLabel}${T.styles[config.style]}`)
|
|
162
196
|
if (config.headingLists && T.headingLists[config.headingLists]) parts.push(T.headingLists[config.headingLists])
|
|
163
197
|
if (config.emoji && T.emoji[config.emoji]) parts.push(T.emoji[config.emoji])
|
|
198
|
+
if (config.tables && T.tables[config.tables]) parts.push(T.tables[config.tables])
|
|
199
|
+
if (config.replyLength && T.replyLength[config.replyLength]) parts.push(T.replyLength[config.replyLength])
|
|
164
200
|
if (config.language && T.replyLanguage[config.language]) parts.push(T.replyLanguage[config.language])
|
|
165
201
|
return parts.join(T.joiner)
|
|
166
202
|
}
|
|
@@ -176,29 +212,48 @@ const MAX_BODY_BYTES = 64 * 1024
|
|
|
176
212
|
// 内存中的配置缓存
|
|
177
213
|
let configCache = null
|
|
178
214
|
|
|
179
|
-
//
|
|
215
|
+
// 磁盘上的配置存在但不可用(读不出来 / 不是合法 JSON)时的记录。
|
|
216
|
+
// 置位后 saveConfig 会拒绝写入,避免把损坏的配置静默清空成默认值。
|
|
217
|
+
let configLoadFailure = null
|
|
218
|
+
|
|
219
|
+
// 读取配置。
|
|
220
|
+
//
|
|
221
|
+
// 文件不存在(首次运行)与文件损坏是两件不同的事,必须分开处理:
|
|
222
|
+
// - 不存在 → 回退默认值,正常路径,可以照常保存;
|
|
223
|
+
// - 损坏 → 也回退默认值(保证插件与设置页仍可用),但记录失败原因,
|
|
224
|
+
// 并让写入路径拒绝保存。否则「回退默认」会在下一次保存时变成
|
|
225
|
+
// 「以默认值为底写回磁盘」,用户的自定义指令与预设库就永久丢了。
|
|
226
|
+
//
|
|
227
|
+
// 失败时刻意**不写 configCache**:这样用户修好文件后,下一次读取会自动恢复正常,
|
|
228
|
+
// 不必重启 DSH。
|
|
180
229
|
async function loadConfig() {
|
|
181
230
|
if (configCache) return configCache
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
231
|
+
configLoadFailure = null
|
|
232
|
+
|
|
233
|
+
const { value, exists, failure } = await readConfigFile(configPath())
|
|
234
|
+
if (failure) {
|
|
235
|
+
configLoadFailure = failure
|
|
236
|
+
return { ...DEFAULT_CONFIG }
|
|
188
237
|
}
|
|
238
|
+
|
|
239
|
+
configCache = exists ? migrateConfig(value) : { ...DEFAULT_CONFIG }
|
|
189
240
|
return configCache
|
|
190
241
|
}
|
|
191
242
|
|
|
192
|
-
//
|
|
243
|
+
// 保存配置(原子写,见 lib/store.mjs 的 writeConfigFile)。
|
|
244
|
+
// 磁盘配置损坏时拒绝写入:拒绝的原因与恢复方式由 lib/store.mjs 统一生成。
|
|
193
245
|
async function saveConfig(config) {
|
|
194
|
-
const
|
|
195
|
-
|
|
246
|
+
const refusal = configWriteRefusal(configLoadFailure, configPath())
|
|
247
|
+
if (refusal) {
|
|
248
|
+
throw new Error(refusal)
|
|
249
|
+
}
|
|
250
|
+
|
|
196
251
|
// 过滤已废弃字段(旧客户端可能仍携带 tone / presets / examples)
|
|
197
252
|
const clean = { ...config }
|
|
198
253
|
delete clean.tone
|
|
199
254
|
delete clean.presets
|
|
200
255
|
delete clean.examples
|
|
201
|
-
await
|
|
256
|
+
await writeConfigFile(configPath(), clean)
|
|
202
257
|
configCache = clean
|
|
203
258
|
return clean
|
|
204
259
|
}
|
|
@@ -255,6 +310,31 @@ function defaultConfigPreservingPersonas(current) {
|
|
|
255
310
|
return next
|
|
256
311
|
}
|
|
257
312
|
|
|
313
|
+
// 重置:配置损坏时的**逃生口**。
|
|
314
|
+
//
|
|
315
|
+
// 损坏状态下所有写入都被 saveConfig 拒绝 —— 包括重置。但「一个出口都没有」意味着
|
|
316
|
+
// 用户只能自己去删文件,所以「重置」被定义为**刻意丢弃**:先把损坏的文件移开
|
|
317
|
+
// (=备份到 <config>.corrupt),路径随即回到「首次运行」分支,这次写入才能真正落盘。
|
|
318
|
+
//
|
|
319
|
+
// 这与 R3 的「损坏配置绝不**静默**被覆盖」不冲突:静默覆盖被禁止,而重置是用户显式
|
|
320
|
+
// 发起的动作、且覆盖前先留下备份。
|
|
321
|
+
//
|
|
322
|
+
// 复用 commitConfig 的「读—改—写」,不另写一份;写入失败时恢复失败标记,
|
|
323
|
+
// 避免把「文件仍然不可用」误报成正常状态。
|
|
324
|
+
async function resetConfigWithEscape() {
|
|
325
|
+
const failure = configLoadFailure
|
|
326
|
+
const backupPath = failure ? await moveAsideConfigFile(configPath()) : null
|
|
327
|
+
if (failure) configLoadFailure = null
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const result = await commitConfig((current) => defaultConfigPreservingPersonas(current))
|
|
331
|
+
return { ...result, backupPath }
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (failure) configLoadFailure = failure
|
|
334
|
+
throw err
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
258
338
|
// 将字段级校验错误汇总为一行文本(HTTP 错误信息 / 工具返回消息共用)
|
|
259
339
|
function formatFieldErrors(errors) {
|
|
260
340
|
return Object.entries(errors)
|
|
@@ -329,14 +409,81 @@ function compilePrompt(config) {
|
|
|
329
409
|
return parts.join('\n')
|
|
330
410
|
}
|
|
331
411
|
|
|
332
|
-
// ====================
|
|
412
|
+
// ==================== 配置生效链路的送达诊断 ====================
|
|
413
|
+
|
|
414
|
+
// 「改配置 → 下一轮生效」由两条通道共同承载:system prompt section(底座)与
|
|
415
|
+
// 活动会话注入(立即见效)。任一条静默失效,表面症状都是「改了人设不生效」而没有
|
|
416
|
+
// 任何报错 —— 正是本插件历史上最难排查的一类问题(见 DEBUGGING.md 3.5)。
|
|
417
|
+
//
|
|
418
|
+
// 因此这里如实记录事实,并让三处可读到:日志(同因只告一次)、GET /api/soul/status、
|
|
419
|
+
// /soul show。诊断本身不改变行为,只在真的出问题时才说话(正常状态不打扰)。
|
|
420
|
+
const deliveryState = {
|
|
421
|
+
injectedAt: null, // 最近一次注入尝试的时间(ISO)
|
|
422
|
+
agents: 0, // 最近一次看到的活动会话数
|
|
423
|
+
delivered: 0, // 最近一次成功注入的会话数
|
|
424
|
+
failed: 0, // 最近一次注入抛错的会话数
|
|
425
|
+
injectionProblem: null, // 未能如实送达的原因;正常为 null
|
|
426
|
+
sectionRegisteredAt: null // section 最近一次注册成功的时间(ISO)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 同一原因只告一次警,避免每次保存都刷屏;一旦恢复正常则清掉记录,
|
|
430
|
+
// 这样「好了又坏」会再次告警。
|
|
431
|
+
const warnedReasons = new Set()
|
|
333
432
|
|
|
334
|
-
|
|
433
|
+
function warnOnce(ctx, reason, message) {
|
|
434
|
+
if (warnedReasons.has(reason)) return
|
|
435
|
+
warnedReasons.add(reason)
|
|
436
|
+
try {
|
|
437
|
+
ctx.logger?.warn?.(`[dsh-soul] ${message}`)
|
|
438
|
+
} catch {
|
|
439
|
+
// 日志不可用不应影响功能
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 把内部原因码翻译成一句能直接看懂的话(/soul show、设置页、状态端点共用)。
|
|
444
|
+
//
|
|
445
|
+
// 文案取自 **COMMAND_MESSAGES**(不是 PROMPT_TEXT):delivery* 系列键只定义在命令文案表里,
|
|
446
|
+
// 两者都按 config.language 提供中英两套。这里此前误用 promptTextOf ⇒ 该函数在
|
|
447
|
+
// 'agents-service-unavailable' 分支返回 undefined(JSON 序列化时字段整个消失,故障对
|
|
448
|
+
// 用户不可见),在 'inject-failed' 分支直接抛 TypeError(保存已落盘却报 HTTP 500)。
|
|
449
|
+
function deliveryProblemText(config) {
|
|
450
|
+
const problem = deliveryState.injectionProblem
|
|
451
|
+
if (!problem) return null
|
|
452
|
+
const T = commandMessages(config)
|
|
453
|
+
if (problem === 'agents-service-unavailable') return T.deliveryNoAgentsService
|
|
454
|
+
return T.deliveryInjectFailed(problem.replace(/^inject-failed:\s*/, ''))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 把最新配置作为 model-facing context 注入所有活动会话。
|
|
335
458
|
// inject() 不会唤醒 Agent,也不改写普通用户消息;会在下一次 step 被模型读取。
|
|
336
459
|
function injectPromptToAllAgents(ctx, config) {
|
|
460
|
+
const stamp = new Date().toISOString()
|
|
461
|
+
const unavailable = (reason, message) => {
|
|
462
|
+
deliveryState.injectedAt = stamp
|
|
463
|
+
deliveryState.agents = 0
|
|
464
|
+
deliveryState.delivered = 0
|
|
465
|
+
deliveryState.failed = 0
|
|
466
|
+
deliveryState.injectionProblem = reason
|
|
467
|
+
warnOnce(ctx, reason, message)
|
|
468
|
+
}
|
|
469
|
+
|
|
337
470
|
const agents = ctx.get('agents')
|
|
338
471
|
if (!agents || typeof agents.list !== 'function') {
|
|
339
|
-
|
|
472
|
+
unavailable(
|
|
473
|
+
'agents-service-unavailable',
|
|
474
|
+
'agents 服务不可用:新配置只能经 system prompt section 送达,保存后可能要到新会话才生效'
|
|
475
|
+
)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let listed
|
|
480
|
+
try {
|
|
481
|
+
listed = agents.list()
|
|
482
|
+
} catch (err) {
|
|
483
|
+
unavailable(
|
|
484
|
+
'agents-service-unavailable',
|
|
485
|
+
`列出活动会话失败(${String((err && err.message) || err)}):新配置只能经 system prompt section 送达`
|
|
486
|
+
)
|
|
340
487
|
return
|
|
341
488
|
}
|
|
342
489
|
|
|
@@ -344,7 +491,11 @@ function injectPromptToAllAgents(ctx, config) {
|
|
|
344
491
|
const prompt = compilePrompt(config)
|
|
345
492
|
const snapshotText = prompt || T.injectDisabled
|
|
346
493
|
|
|
347
|
-
|
|
494
|
+
let delivered = 0
|
|
495
|
+
let failed = 0
|
|
496
|
+
let lastError = null
|
|
497
|
+
|
|
498
|
+
for (const agent of listed) {
|
|
348
499
|
try {
|
|
349
500
|
agent.inject(createUserMessage({
|
|
350
501
|
content: [{
|
|
@@ -355,17 +506,39 @@ function injectPromptToAllAgents(ctx, config) {
|
|
|
355
506
|
// 会让每一轮在 step 开始前失败(issue #1,构造与校验见 lib/injection.mjs)
|
|
356
507
|
source: createInjectionSource(snapshotText)
|
|
357
508
|
}))
|
|
358
|
-
|
|
509
|
+
delivered++
|
|
359
510
|
} catch (err) {
|
|
360
|
-
|
|
511
|
+
failed++
|
|
512
|
+
lastError = err
|
|
513
|
+
try {
|
|
514
|
+
ctx.logger?.warn?.(`[dsh-soul] 向会话 ${agent?.id} 注入配置失败:${String((err && err.message) || err)}`)
|
|
515
|
+
} catch {
|
|
516
|
+
// 同上:日志失败不影响功能
|
|
517
|
+
}
|
|
361
518
|
}
|
|
362
519
|
}
|
|
520
|
+
|
|
521
|
+
deliveryState.injectedAt = stamp
|
|
522
|
+
deliveryState.agents = listed.length
|
|
523
|
+
deliveryState.delivered = delivered
|
|
524
|
+
deliveryState.failed = failed
|
|
525
|
+
deliveryState.injectionProblem = failed > 0
|
|
526
|
+
? `inject-failed: ${String((lastError && lastError.message) || lastError)}`
|
|
527
|
+
: null
|
|
528
|
+
|
|
529
|
+
// 恢复正常后允许再次告警(「好了又坏」不该被静默)
|
|
530
|
+
if (failed === 0) warnedReasons.delete('agents-service-unavailable')
|
|
531
|
+
else warnedReasons.add('agents-service-unavailable')
|
|
363
532
|
}
|
|
364
533
|
|
|
365
534
|
function refreshPromptAndInject(ctx, config) {
|
|
366
535
|
if (globalUpdatePrompt) {
|
|
367
536
|
globalUpdatePrompt().catch(err => {
|
|
368
|
-
|
|
537
|
+
try {
|
|
538
|
+
ctx.logger?.warn?.(`[dsh-soul] 重新注册 system prompt section 失败:${String((err && err.message) || err)}`)
|
|
539
|
+
} catch {
|
|
540
|
+
// 同上
|
|
541
|
+
}
|
|
369
542
|
})
|
|
370
543
|
}
|
|
371
544
|
injectPromptToAllAgents(ctx, config)
|
|
@@ -388,7 +561,18 @@ function registerRoutes(ctx) {
|
|
|
388
561
|
try {
|
|
389
562
|
if (req.method === 'GET') {
|
|
390
563
|
const config = await loadConfig()
|
|
391
|
-
|
|
564
|
+
// configError:磁盘配置存在但不可用时上报(此时 config 是回退的默认值)。
|
|
565
|
+
// 让设置页能直接说明「人设为什么像是被重置了」,而不是让用户自己发现;
|
|
566
|
+
// 正常情况下该字段为 null,不影响既有客户端。
|
|
567
|
+
send(200, {
|
|
568
|
+
ok: true,
|
|
569
|
+
config,
|
|
570
|
+
configError: configLoadFailure ? describeConfigFailure(configLoadFailure) : null,
|
|
571
|
+
// deliveryWarning:配置送达异常时上报(例如 agents 服务不可用)。
|
|
572
|
+
// 与 configError 分开:configError 是「配置读不出来」,这里是「配置送不到会话」,
|
|
573
|
+
// 两者症状都表现为「改了人设不生效」,但处理方式完全不同。
|
|
574
|
+
deliveryWarning: deliveryProblemText(config)
|
|
575
|
+
})
|
|
392
576
|
} else if (req.method === 'POST') {
|
|
393
577
|
const parsed = await readJsonBody(req)
|
|
394
578
|
if (!parsed.ok) {
|
|
@@ -412,7 +596,7 @@ function registerRoutes(ctx) {
|
|
|
412
596
|
refreshPromptAndInject(ctx, updated)
|
|
413
597
|
}
|
|
414
598
|
|
|
415
|
-
send(200, { ok: true, config: updated, changed })
|
|
599
|
+
send(200, { ok: true, config: updated, changed, deliveryWarning: deliveryProblemText(updated) })
|
|
416
600
|
} else {
|
|
417
601
|
send(405, { ok: false, error: 'Method not allowed' })
|
|
418
602
|
}
|
|
@@ -433,6 +617,13 @@ function registerRoutes(ctx) {
|
|
|
433
617
|
}
|
|
434
618
|
|
|
435
619
|
try {
|
|
620
|
+
// 方法校验:本端点只读。此前是唯一不判 method 的端点(同文件其余 7 处都有
|
|
621
|
+
// 405 分支),实测 POST /api/soul/prompt 会拿到 200 —— 宿主只做路径匹配,
|
|
622
|
+
// method 判定确实是 handler 的责任。
|
|
623
|
+
if (req.method !== 'GET') {
|
|
624
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
625
|
+
return
|
|
626
|
+
}
|
|
436
627
|
const config = await loadConfig()
|
|
437
628
|
const prompt = compilePrompt(config)
|
|
438
629
|
send(200, { ok: true, prompt, enabled: config.enabled })
|
|
@@ -442,7 +633,50 @@ function registerRoutes(ctx) {
|
|
|
442
633
|
}
|
|
443
634
|
})
|
|
444
635
|
|
|
445
|
-
//
|
|
636
|
+
// 预览「未保存的编辑」编译出的 system prompt:以已保存配置为底,用请求体中
|
|
637
|
+
// 通过校验的字段覆盖后编译。
|
|
638
|
+
//
|
|
639
|
+
// **不落盘、不注入会话** —— 纯只读旁路,供设置在保存前确认真正会被注入的内容。
|
|
640
|
+
// v0.1.x 曾有同类端点,随 v0.2.0 精简重构下线;此处恢复并修掉旧实现的一个疏漏:
|
|
641
|
+
// 旧版直接展开请求体({ ...current, ...draft })未经校验,而这里与保存路径共用
|
|
642
|
+
// 同一套 sanitizeConfig —— 非法字段既不参与预览、也不会落盘,并通过 invalid
|
|
643
|
+
// 回报给客户端,避免出现「预览悄悄沿用了旧值」这种看不出原因的偏差。
|
|
644
|
+
wsCtx.webServer.register({
|
|
645
|
+
kind: 'exact',
|
|
646
|
+
path: '/api/soul/prompt/preview',
|
|
647
|
+
handler: async (req, res) => {
|
|
648
|
+
const send = (status, body) => {
|
|
649
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
650
|
+
res.end(JSON.stringify(body))
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
if (req.method !== 'POST') {
|
|
655
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const parsed = await readJsonBody(req)
|
|
660
|
+
if (!parsed.ok) {
|
|
661
|
+
send(parsed.status, { ok: false, error: parsed.error })
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const { patch, errors } = sanitizeConfig(parsed.body)
|
|
666
|
+
const current = await loadConfig()
|
|
667
|
+
const draft = { ...current, ...patch }
|
|
668
|
+
|
|
669
|
+
send(200, {
|
|
670
|
+
ok: true,
|
|
671
|
+
prompt: compilePrompt(draft),
|
|
672
|
+
enabled: draft.enabled,
|
|
673
|
+
invalid: Object.keys(errors)
|
|
674
|
+
})
|
|
675
|
+
} catch (err) {
|
|
676
|
+
send(500, { ok: false, error: String(err.message || err) })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
})
|
|
446
680
|
|
|
447
681
|
// 重置配置
|
|
448
682
|
wsCtx.webServer.register({
|
|
@@ -455,14 +689,67 @@ function registerRoutes(ctx) {
|
|
|
455
689
|
}
|
|
456
690
|
|
|
457
691
|
try {
|
|
458
|
-
|
|
692
|
+
// 经由逃生口:配置损坏时这是设置页唯一还能成功的写操作。
|
|
693
|
+
// backupPath 非 null 说明刚才确实丢弃了一份损坏的配置(已备份),
|
|
694
|
+
// 客户端据此给出「备份在哪」的提示,而不是让用户以为数据凭空消失。
|
|
695
|
+
const { config, changed, promptChanged, backupPath } = await resetConfigWithEscape()
|
|
459
696
|
|
|
460
697
|
// 仅影响 Agent 行为的字段变化才刷新提示词并注入会话
|
|
461
698
|
if (promptChanged.length > 0) {
|
|
462
699
|
refreshPromptAndInject(ctx, config)
|
|
463
700
|
}
|
|
464
701
|
|
|
465
|
-
send(200, { ok: true, config, changed })
|
|
702
|
+
send(200, { ok: true, config, changed, backupPath, deliveryWarning: deliveryProblemText(config) })
|
|
703
|
+
} catch (err) {
|
|
704
|
+
send(500, { ok: false, error: String(err.message || err) })
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
// 生效链路诊断:两条通道(system prompt section / 活动会话注入)的当前状态。
|
|
710
|
+
// 设置页与 /soul show 之外,这是给「改了配置不生效」提供的第三个、也是脚本化的观察点。
|
|
711
|
+
wsCtx.webServer.register({
|
|
712
|
+
kind: 'exact',
|
|
713
|
+
path: '/api/soul/status',
|
|
714
|
+
handler: async (req, res) => {
|
|
715
|
+
const send = (status, body) => {
|
|
716
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
717
|
+
res.end(JSON.stringify(body))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
if (req.method !== 'GET') {
|
|
722
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const config = await loadConfig()
|
|
727
|
+
const injection = {
|
|
728
|
+
lastAt: deliveryState.injectedAt,
|
|
729
|
+
agents: deliveryState.agents,
|
|
730
|
+
delivered: deliveryState.delivered,
|
|
731
|
+
failed: deliveryState.failed,
|
|
732
|
+
problem: deliveryState.injectionProblem
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
send(200, {
|
|
736
|
+
ok: true,
|
|
737
|
+
version: pluginVersion(),
|
|
738
|
+
enabled: config.enabled,
|
|
739
|
+
configPath: configPath(),
|
|
740
|
+
configError: configLoadFailure ? describeConfigFailure(configLoadFailure) : null,
|
|
741
|
+
promptChars: compilePrompt(config).length,
|
|
742
|
+
channels: {
|
|
743
|
+
// section 是底座:它一旦注册成功,宿主每一步装配都会重新求值
|
|
744
|
+
// (不依赖任何缓存,见 scripts/verify-live-prompt.mjs)
|
|
745
|
+
section: {
|
|
746
|
+
registered: deliveryState.sectionRegisteredAt !== null,
|
|
747
|
+
registeredAt: deliveryState.sectionRegisteredAt
|
|
748
|
+
},
|
|
749
|
+
injection
|
|
750
|
+
},
|
|
751
|
+
deliveryWarning: deliveryProblemText(config)
|
|
752
|
+
})
|
|
466
753
|
} catch (err) {
|
|
467
754
|
send(500, { ok: false, error: String(err.message || err) })
|
|
468
755
|
}
|
|
@@ -478,14 +765,21 @@ function registerRoutes(ctx) {
|
|
|
478
765
|
// 待确认的人设变更提议(set_persona 确认模式;进程内短生命周期,不做持久化)
|
|
479
766
|
let pendingPersonaProposal = null
|
|
480
767
|
|
|
481
|
-
//
|
|
768
|
+
// 从配置中提取人设快照(作为预设保存的内容)。
|
|
769
|
+
//
|
|
770
|
+
// 只遍历 PERSONA_FIELDS ⇒ **不含「关于你」(昵称 / 职业 / 介绍)**:预设是 Agent 的
|
|
771
|
+
// 人格存档,不是使用者身份存档。切换预设不该把你的昵称职业一起换掉。
|
|
772
|
+
// 输出语言在范围内(它决定 Agent 用什么语言作答)。
|
|
482
773
|
function personaSnapshotOf(config) {
|
|
483
774
|
const values = {}
|
|
484
775
|
for (const key of PERSONA_FIELDS) values[key] = config[key]
|
|
485
776
|
return values
|
|
486
777
|
}
|
|
487
778
|
|
|
488
|
-
// 从预设条目中挑出人设字段(忽略 updatedAt
|
|
779
|
+
// 从预设条目中挑出人设字段(忽略 updatedAt 等元数据与「关于你」)。
|
|
780
|
+
// `if (key in persona)` 即「部分覆盖」语义:只应用条目**实际声明**的键,
|
|
781
|
+
// 未声明的保持用户当前值;而遍历白名单本身则是第二道防线——即便磁盘上残留了
|
|
782
|
+
// nickname / occupation / bio(历史数据),也不会被写回活动配置。
|
|
489
783
|
function pickPersonaValues(persona) {
|
|
490
784
|
const values = {}
|
|
491
785
|
for (const key of PERSONA_FIELDS) {
|
|
@@ -505,16 +799,42 @@ function hasOwn(obj, key) {
|
|
|
505
799
|
return Object.prototype.hasOwnProperty.call(obj, key)
|
|
506
800
|
}
|
|
507
801
|
|
|
508
|
-
//
|
|
802
|
+
// 计算与当前活动配置匹配的预设名(多个命中时取名称排序最前)。
|
|
803
|
+
// 匹配语义是「部分覆盖」而非全字段相等,理由见 lib/personas.mjs 的 personaMatches。
|
|
509
804
|
function findActivePersonaName(config) {
|
|
510
|
-
const
|
|
511
|
-
for (const personaName of Object.keys(
|
|
512
|
-
|
|
513
|
-
if (PERSONA_FIELDS.every((key) => entry[key] === config[key])) return personaName
|
|
805
|
+
const view = mergePersonas(config.personas)
|
|
806
|
+
for (const personaName of Object.keys(view).sort()) {
|
|
807
|
+
if (personaMatches(view[personaName], config)) return personaName
|
|
514
808
|
}
|
|
515
809
|
return null
|
|
516
810
|
}
|
|
517
811
|
|
|
812
|
+
// 应用一个预设条目:过白名单 → 写队列合并。
|
|
813
|
+
//
|
|
814
|
+
// **必须回报被丢弃的字段**:normalizePersonas 只按字段名过滤、不校验取值,所以磁盘上
|
|
815
|
+
// 残留的历史值(例如 v0.1.x 的合法 style 'friendly')或手改的异常值会走到这里被
|
|
816
|
+
// sanitizeConfig 拒绝。此前两条应用路径都只取 patch、把 errors 丢掉 ⇒ 预设被「部分
|
|
817
|
+
// 应用」却返回 ok:true / 「已应用」,用户以为整套生效了(实测:style 与 emoji 被静默
|
|
818
|
+
// 丢弃,只有 customInstructions 写回)。
|
|
819
|
+
//
|
|
820
|
+
// 语义选择:应用**合法部分**并如实回报被丢弃的字段,而不是整单拒绝 —— 后者会让一个
|
|
821
|
+
// 只差一个字段的旧预设彻底不可用,代价更高;而「部分应用 + 明示」与 /api/soul/prompt/
|
|
822
|
+
// preview 的 invalid 约定一致。
|
|
823
|
+
async function applyPersonaEntry(ctx, entry) {
|
|
824
|
+
const { patch, errors } = sanitizeConfig(pickPersonaValues(entry))
|
|
825
|
+
const invalid = Object.keys(errors)
|
|
826
|
+
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
|
|
827
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
828
|
+
// applied 是「通过校验的字段数」——与 changed(实际产生差异的字段)不同:
|
|
829
|
+
// 全部非法时 applied 为 0,此时不能报「已应用」,也不能报「与当前配置一致」。
|
|
830
|
+
return { updated, changed, promptChanged, invalid, applied: Object.keys(patch).length }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// 预设库的对外视图(内置 + 用户)与当前命中项,供三个路由的响应复用。
|
|
834
|
+
function personaLibrary(config) {
|
|
835
|
+
return { personas: mergePersonas(config.personas), activeName: findActivePersonaName(config) }
|
|
836
|
+
}
|
|
837
|
+
|
|
518
838
|
// 人设预设 HTTP 路由:列表 / 保存 / 使用 / 删除。
|
|
519
839
|
// 保存与删除仅变更 personas 库(diffKeys 跳过该字段),不会触发会话注入;
|
|
520
840
|
// 使用会变更活动配置字段,走常规 promptChanged 门控注入。
|
|
@@ -536,7 +856,7 @@ function registerPersonaRoutes(ctx) {
|
|
|
536
856
|
return
|
|
537
857
|
}
|
|
538
858
|
const config = await loadConfig()
|
|
539
|
-
send(res, 200, { ok: true,
|
|
859
|
+
send(res, 200, { ok: true, ...personaLibrary(config) })
|
|
540
860
|
} catch (err) {
|
|
541
861
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
542
862
|
}
|
|
@@ -563,12 +883,18 @@ function registerPersonaRoutes(ctx) {
|
|
|
563
883
|
send(res, 400, { ok: false, error: `预设名称无效(1-${PERSONA_NAME_MAX} 个字符)` })
|
|
564
884
|
return
|
|
565
885
|
}
|
|
886
|
+
// 内置名一律不可写入:内置预设只存在于代码里,若允许同名落盘,合并视图会
|
|
887
|
+
// 遮蔽它,用户就会得到一个「保存了却看不见、也删不掉」的幽灵条目。
|
|
888
|
+
if (isBuiltinPersona(personaName)) {
|
|
889
|
+
send(res, 400, { ok: false, error: `「${personaName}」是内置预设,请换一个名称` })
|
|
890
|
+
return
|
|
891
|
+
}
|
|
566
892
|
const { config: updated } = await commitConfig((current) => {
|
|
567
893
|
const personas = clonePersonas(current.personas)
|
|
568
894
|
personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
|
|
569
895
|
return { ...current, personas }
|
|
570
896
|
})
|
|
571
|
-
send(res, 200, { ok: true,
|
|
897
|
+
send(res, 200, { ok: true, ...personaLibrary(updated) })
|
|
572
898
|
} catch (err) {
|
|
573
899
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
574
900
|
}
|
|
@@ -592,17 +918,22 @@ function registerPersonaRoutes(ctx) {
|
|
|
592
918
|
}
|
|
593
919
|
const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
|
|
594
920
|
const current = await loadConfig()
|
|
595
|
-
|
|
921
|
+
// 内置与用户预设共用同一条应用路径(resolvePersona 先内置后用户)
|
|
922
|
+
const entry = personaName ? resolvePersona(personaName, current.personas) : null
|
|
923
|
+
if (!entry) {
|
|
596
924
|
send(res, 400, { ok: false, error: `预设不存在:${personaName || '(空)'}` })
|
|
597
925
|
return
|
|
598
926
|
}
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
927
|
+
// 内置预设已在代码里写过一遍;用户预设保存时也校验过。此处统一再过一遍
|
|
928
|
+
// 白名单,防御手改 config.json 注入的异常数据。
|
|
929
|
+
// invalid = 因取值非法而被丢弃的字段名:必须回报,不能静默部分应用。
|
|
930
|
+
const { updated, changed, invalid, applied } = await applyPersonaEntry(ctx, entry)
|
|
931
|
+
// 一个合法字段都没有 ⇒ 什么都没应用,按失败处理(与 /soul use 同语义)
|
|
932
|
+
if (applied === 0) {
|
|
933
|
+
send(res, 400, { ok: false, error: `预设「${personaName}」没有可应用的合法字段`, invalid })
|
|
934
|
+
return
|
|
604
935
|
}
|
|
605
|
-
send(res, 200, { ok: true, config: updated, changed, unchanged: changed.length === 0 })
|
|
936
|
+
send(res, 200, { ok: true, config: updated, changed, unchanged: changed.length === 0, invalid })
|
|
606
937
|
} catch (err) {
|
|
607
938
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
608
939
|
}
|
|
@@ -625,6 +956,13 @@ function registerPersonaRoutes(ctx) {
|
|
|
625
956
|
return
|
|
626
957
|
}
|
|
627
958
|
const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
|
|
959
|
+
// 内置预设不可删除。这一层必须挡在「磁盘库是否存在」之前:内置项本来就不在
|
|
960
|
+
// 磁盘库里,但手改文件可能留下同名残留,那种条目已被合并视图遮蔽、删它没有
|
|
961
|
+
// 意义,而调用方真正想删的是内置项——直接拒绝,语义才唯一。
|
|
962
|
+
if (personaName && isBuiltinPersona(personaName)) {
|
|
963
|
+
send(res, 400, { ok: false, error: `「${personaName}」是内置预设,无法删除` })
|
|
964
|
+
return
|
|
965
|
+
}
|
|
628
966
|
const current = await loadConfig()
|
|
629
967
|
if (!personaName || !hasOwn(current.personas || {}, personaName)) {
|
|
630
968
|
send(res, 400, { ok: false, error: `预设不存在:${personaName || '(空)'}` })
|
|
@@ -635,7 +973,7 @@ function registerPersonaRoutes(ctx) {
|
|
|
635
973
|
delete personas[personaName]
|
|
636
974
|
return { ...c, personas }
|
|
637
975
|
})
|
|
638
|
-
send(res, 200, { ok: true,
|
|
976
|
+
send(res, 200, { ok: true, ...personaLibrary(updated) })
|
|
639
977
|
} catch (err) {
|
|
640
978
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
641
979
|
}
|
|
@@ -667,11 +1005,19 @@ const COMMAND_MESSAGES = {
|
|
|
667
1005
|
traitsLabel: '特质',
|
|
668
1006
|
headingListsLabel: '标题和列表',
|
|
669
1007
|
emojiLabel: '表情符号',
|
|
1008
|
+
tablesLabel: '表格',
|
|
1009
|
+
replyLengthLabel: '回复长度',
|
|
670
1010
|
traitNames: {
|
|
671
1011
|
default: '默认',
|
|
672
1012
|
more: '增强',
|
|
673
1013
|
less: '减弱'
|
|
674
1014
|
},
|
|
1015
|
+
replyLengthNames: {
|
|
1016
|
+
concise: '简洁',
|
|
1017
|
+
normal: '适中',
|
|
1018
|
+
detailed: '详尽'
|
|
1019
|
+
},
|
|
1020
|
+
listSep: ',',
|
|
675
1021
|
trailLabel: '输入框光轨',
|
|
676
1022
|
trailLine: (status, color, speedName, widthName) => `${status}(${color} / ${speedName} / ${widthName})`,
|
|
677
1023
|
trailSpeedNames: {
|
|
@@ -692,27 +1038,45 @@ const COMMAND_MESSAGES = {
|
|
|
692
1038
|
onLabel: '开启',
|
|
693
1039
|
offLabel: '关闭',
|
|
694
1040
|
personasLabel: '人设预设',
|
|
695
|
-
|
|
1041
|
+
// 提议是**内存态**(重启即失效)、也没有 TTL,所以必须让用户看到它是什么时候提出的,
|
|
1042
|
+
// 否则一条陈旧提议会一直挂着而无法判断新鲜度(proposedAt 此前只写不读)。
|
|
1043
|
+
pendingHint: (at) => `⚠ 有待确认的人设变更(提出于 ${at}):/soul confirm 确认,/soul reject 拒绝`,
|
|
696
1044
|
noPending: '没有待确认的人设变更',
|
|
697
1045
|
confirmDone: (detail) => `✅ 已应用人设变更:${detail}`,
|
|
698
1046
|
rejectDone: '✅ 已拒绝待确认的人设变更',
|
|
699
1047
|
saveDone: (name) => `✅ 已保存预设「${name}」`,
|
|
1048
|
+
// 内置预设(随插件发布、不落盘):不可用同名保存,也不可删除
|
|
1049
|
+
builtinName: '内置',
|
|
1050
|
+
builtinSaveBlocked: (name) => `「${name}」是内置预设,请换一个名称`,
|
|
1051
|
+
builtinDeleteBlocked: (name) => `「${name}」是内置预设,无法删除`,
|
|
700
1052
|
useDone: (name) => `✅ 已应用预设「${name}」`,
|
|
701
1053
|
useUnchanged: (name) => `ℹ 预设「${name}」与当前配置一致,无需变更`,
|
|
1054
|
+
// 预设里部分字段的取值非法(历史值或手改):合法字段照常应用,被跳过的必须说出来
|
|
1055
|
+
usePartial: (name, fields) => `⚠ 预设「${name}」已部分应用,以下字段取值非法、已跳过:${fields}`,
|
|
1056
|
+
useAllInvalid: (name, fields) => `✗ 预设「${name}」没有可应用的合法字段:${fields}`,
|
|
702
1057
|
personaMissing: (name) => `预设「${name}」不存在`,
|
|
703
1058
|
delDone: (name) => `✅ 已删除预设「${name}」`,
|
|
704
1059
|
listTitle: '📋 人设预设',
|
|
705
1060
|
listEmpty: '暂无人设预设,使用 /soul save <名称> 保存当前配置',
|
|
1061
|
+
// 列表行报告预设实际覆盖的人格维度数量(取代 v0.7.1 起恒为空的「昵称」列)
|
|
1062
|
+
listCovers: (n) => `覆盖 ${n} 项`,
|
|
706
1063
|
saveUsage: '用法:/soul save <名称>(1-30 个字符)',
|
|
707
1064
|
useUsage: '用法:/soul use <名称>',
|
|
708
1065
|
delUsage: '用法:/soul del <名称>',
|
|
709
|
-
setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
1066
|
+
setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
710
1067
|
unknownField: '未知配置项',
|
|
711
|
-
invalidBoolean: (key) => `${key} 取值必须为 true / false
|
|
1068
|
+
invalidBoolean: (key, value) => `${key} 取值必须为 true / false(收到:${JSON.stringify(value)})`,
|
|
712
1069
|
setNoChanges: '配置无变化,未做修改',
|
|
713
1070
|
setDone: (keys) => `✅ 已更新:${keys.join('、')}`,
|
|
714
1071
|
help: '使用 /soul show 查看详情\n使用 /soul set style=humorous 修改配置项\n使用 /soul save <名称> 保存预设;/soul use <名称> 应用;/soul list 查看;/soul del <名称> 删除\n使用 /soul confirm 或 /soul reject 处理待确认的人设变更\n使用 /soul reset 重置配置\n使用 /soul enable 启用;/soul disable 禁用\n使用 /soul 昵称xxx 设置昵称',
|
|
715
1072
|
resetDone: '✅ 配置已重置为默认值',
|
|
1073
|
+
resetRecovered: (file) => `✅ 配置已重置为默认值(损坏的文件已备份为 ${file})`,
|
|
1074
|
+
deliveryLabel: '送达:',
|
|
1075
|
+
deliveryOk: (delivered, total) => total === 0
|
|
1076
|
+
? '暂无活动会话(配置将在新会话生效)'
|
|
1077
|
+
: `已注入 ${delivered}/${total} 个活动会话`,
|
|
1078
|
+
deliveryNoAgentsService: 'agents 服务不可用 —— 新配置只能经 system prompt section 送达,保存后可能要到新会话才生效',
|
|
1079
|
+
deliveryInjectFailed: (detail) => `向活动会话注入失败:${detail}`,
|
|
716
1080
|
enableDone: '✅ 个性化设置已启用',
|
|
717
1081
|
disableDone: '✅ 个性化设置已禁用',
|
|
718
1082
|
nicknameDone: (n) => `✅ 昵称已设置为:${n}`,
|
|
@@ -736,11 +1100,19 @@ const COMMAND_MESSAGES = {
|
|
|
736
1100
|
traitsLabel: 'Traits',
|
|
737
1101
|
headingListsLabel: 'Headings & lists',
|
|
738
1102
|
emojiLabel: 'Emoji',
|
|
1103
|
+
tablesLabel: 'Tables',
|
|
1104
|
+
replyLengthLabel: 'Reply length',
|
|
739
1105
|
traitNames: {
|
|
740
1106
|
default: 'Default',
|
|
741
1107
|
more: 'More',
|
|
742
1108
|
less: 'Less'
|
|
743
1109
|
},
|
|
1110
|
+
replyLengthNames: {
|
|
1111
|
+
concise: 'Concise',
|
|
1112
|
+
normal: 'Balanced',
|
|
1113
|
+
detailed: 'Detailed'
|
|
1114
|
+
},
|
|
1115
|
+
listSep: ', ',
|
|
744
1116
|
trailLabel: 'Composer light trail',
|
|
745
1117
|
trailLine: (status, color, speedName, widthName) => `${status} (${color} / ${speedName} / ${widthName})`,
|
|
746
1118
|
trailSpeedNames: {
|
|
@@ -761,27 +1133,44 @@ const COMMAND_MESSAGES = {
|
|
|
761
1133
|
onLabel: 'on',
|
|
762
1134
|
offLabel: 'off',
|
|
763
1135
|
personasLabel: 'Personas',
|
|
764
|
-
|
|
1136
|
+
// 提议是**内存态**(重启即失效)、也没有 TTL,所以必须让用户看到它是什么时候提出的,
|
|
1137
|
+
// 否则一条陈旧提议会一直挂着而无法判断新鲜度(proposedAt 此前只写不读)。
|
|
1138
|
+
pendingHint: (at) => `⚠ Pending persona proposal (raised at ${at}): /soul confirm to apply, /soul reject to discard`,
|
|
765
1139
|
noPending: 'No pending persona proposal',
|
|
766
1140
|
confirmDone: (detail) => `✅ Persona changes applied: ${detail}`,
|
|
767
1141
|
rejectDone: '✅ Pending persona proposal discarded',
|
|
768
1142
|
saveDone: (name) => `✅ Persona "${name}" saved`,
|
|
1143
|
+
builtinName: 'built-in',
|
|
1144
|
+
builtinSaveBlocked: (name) => `"${name}" is a built-in persona — pick another name`,
|
|
1145
|
+
builtinDeleteBlocked: (name) => `"${name}" is a built-in persona and cannot be deleted`,
|
|
769
1146
|
useDone: (name) => `✅ Persona "${name}" applied`,
|
|
770
1147
|
useUnchanged: (name) => `ℹ Persona "${name}" matches the current config`,
|
|
1148
|
+
// 预设里部分字段的取值非法(历史值或手改):合法字段照常应用,被跳过的必须说出来
|
|
1149
|
+
usePartial: (name, fields) => `⚠ Persona "${name}" applied partially; skipped as invalid: ${fields}`,
|
|
1150
|
+
useAllInvalid: (name, fields) => `✗ Persona "${name}" has no applicable field: ${fields}`,
|
|
771
1151
|
personaMissing: (name) => `Persona "${name}" does not exist`,
|
|
772
1152
|
delDone: (name) => `✅ Persona "${name}" deleted`,
|
|
773
1153
|
listTitle: '📋 Personas',
|
|
774
1154
|
listEmpty: 'No personas yet — save the current config with /soul save <name>',
|
|
1155
|
+
// 列表行报告预设实际覆盖的人格维度数量(取代 v0.7.1 起恒为空的「昵称」列)
|
|
1156
|
+
listCovers: (n) => `covers ${n} field${n === 1 ? '' : 's'}`,
|
|
775
1157
|
saveUsage: 'Usage: /soul save <name> (1-30 chars)',
|
|
776
1158
|
useUsage: 'Usage: /soul use <name>',
|
|
777
1159
|
delUsage: 'Usage: /soul del <name>',
|
|
778
|
-
setUsage: 'Usage: /soul set key=value ... (fields: enabled / style / headingLists / emoji / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
1160
|
+
setUsage: 'Usage: /soul set key=value ... (fields: enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
779
1161
|
unknownField: 'Unknown field',
|
|
780
|
-
invalidBoolean: (key) => `${key} must be true or false`,
|
|
1162
|
+
invalidBoolean: (key, value) => `${key} must be true or false (got: ${JSON.stringify(value)})`,
|
|
781
1163
|
setNoChanges: 'No config changes to apply',
|
|
782
1164
|
setDone: (keys) => `✅ Updated: ${keys.join(', ')}`,
|
|
783
1165
|
help: 'Use /soul show for details\nUse /soul set style=humorous to change fields\nUse /soul save <name> / use <name> / list / del <name> for personas\nUse /soul confirm or /soul reject for pending persona proposals\nUse /soul reset to reset\nUse /soul enable / disable to toggle\nUse /soul <nickname> to set your nickname',
|
|
784
1166
|
resetDone: '✅ Configuration reset to defaults',
|
|
1167
|
+
resetRecovered: (file) => `✅ Configuration reset to defaults (the damaged file was backed up as ${file})`,
|
|
1168
|
+
deliveryLabel: 'Delivery: ',
|
|
1169
|
+
deliveryOk: (delivered, total) => total === 0
|
|
1170
|
+
? 'no active sessions (the config will apply in a new session)'
|
|
1171
|
+
: `injected into ${delivered}/${total} active sessions`,
|
|
1172
|
+
deliveryNoAgentsService: 'the `agents` service is unavailable — the new config can only travel via the system prompt section, so it may take a new session to apply',
|
|
1173
|
+
deliveryInjectFailed: (detail) => `injection into active sessions failed: ${detail}`,
|
|
785
1174
|
enableDone: '✅ Personalization enabled',
|
|
786
1175
|
disableDone: '✅ Personalization disabled',
|
|
787
1176
|
nicknameDone: (n) => `✅ Nickname set to: ${n}`,
|
|
@@ -799,6 +1188,17 @@ const COMMAND_KEYWORDS = new Set(['show', 'reset', 'enable', 'disable', 'save',
|
|
|
799
1188
|
// /soul set 中按布尔解析的键;其余键以原始字符串交给 sanitizeConfig 校验
|
|
800
1189
|
const COMMAND_BOOLEAN_KEYS = new Set(['enabled', 'trailEnabled'])
|
|
801
1190
|
|
|
1191
|
+
// 配置字段名 → 小写形式。子命令关键字本就忽略大小写(见 handler 里的 toLowerCase),
|
|
1192
|
+
// 键名此前却严格区分:`/soul set STYLE=humorous` 会报「未知配置项」,与用户预期不一致。
|
|
1193
|
+
// 这里按小写**映射回真实字段名**(不是把键 toLowerCase —— 那会把 headingLists 压成
|
|
1194
|
+
// headinglists,反而匹配不上白名单)。非配置字段仍按未知处理。
|
|
1195
|
+
const COMMAND_FIELD_BY_LOWER = new Map(Object.keys(DEFAULT_CONFIG).map((key) => [key.toLowerCase(), key]))
|
|
1196
|
+
|
|
1197
|
+
// 归一化 /soul set 的键名:命中真实字段则用其准确写法,否则原样交给白名单判未知
|
|
1198
|
+
function normalizeSetKey(key) {
|
|
1199
|
+
return COMMAND_FIELD_BY_LOWER.get(key.toLowerCase()) || key
|
|
1200
|
+
}
|
|
1201
|
+
|
|
802
1202
|
// 解析 /soul set 的键值参数:key=value 对;不含 = 的 token 追加到上一个值
|
|
803
1203
|
// (支持含空格的文本值,如:/soul set bio=写代码 多年 经验)
|
|
804
1204
|
function parseSetArgs(rest) {
|
|
@@ -847,29 +1247,44 @@ function registerCommands(ctx) {
|
|
|
847
1247
|
const occupation = config.occupation || t.notSet
|
|
848
1248
|
const bio = config.bio || t.notSet
|
|
849
1249
|
const styleName = t.styleNames[config.style] || config.style || t.notSet
|
|
850
|
-
const
|
|
851
|
-
|
|
1250
|
+
const traitsLine = [
|
|
1251
|
+
`${t.headingListsLabel}=${t.traitNames[config.headingLists] || config.headingLists}`,
|
|
1252
|
+
`${t.emojiLabel}=${t.traitNames[config.emoji] || config.emoji}`,
|
|
1253
|
+
`${t.tablesLabel}=${t.traitNames[config.tables] || config.tables}`,
|
|
1254
|
+
`${t.replyLengthLabel}=${t.replyLengthNames[config.replyLength] || config.replyLength}`
|
|
1255
|
+
].join(t.listSep)
|
|
852
1256
|
const instructions = config.customInstructions || t.notSet
|
|
853
|
-
|
|
854
|
-
|
|
1257
|
+
// 预设数量用**合并视图**(内置 + 用户),与 /soul list 和 Web UI 一致:
|
|
1258
|
+
// 此前只数 config.personas(磁盘上的用户预设),于是同一个「人设预设」
|
|
1259
|
+
// 在 show 里显示 2、在 list 里显示 9。
|
|
1260
|
+
const personaCount = Object.keys(mergePersonas(config.personas)).length
|
|
1261
|
+
const pendingLine = pendingPersonaProposal
|
|
1262
|
+
? `\n${t.pendingHint(new Date(pendingPersonaProposal.proposedAt).toLocaleString())}`
|
|
1263
|
+
: ''
|
|
855
1264
|
const trailText = t.trailLine(
|
|
856
1265
|
config.trailEnabled ? t.enabled : t.disabled,
|
|
857
1266
|
config.trailColor,
|
|
858
1267
|
t.trailSpeedNames[config.trailSpeed] || config.trailSpeed,
|
|
859
1268
|
t.trailWidthNames[config.trailWidth] || config.trailWidth
|
|
860
1269
|
)
|
|
1270
|
+
// 送达状态:直接回答「我刚改了配置,为什么没生效」这个高频疑问 ——
|
|
1271
|
+
// 两条通道里注入是否真的送到了活动会话,此前完全没有可观测点。
|
|
1272
|
+
const delivery = deliveryProblemText(config) || t.deliveryOk(deliveryState.delivered, deliveryState.agents)
|
|
861
1273
|
|
|
862
1274
|
return {
|
|
863
1275
|
kind: 'success',
|
|
864
|
-
text: `${t.showTitle}\n\n${t.statusLabel}${t.colon}${status}\n${t.nicknameLabel}${t.colon}${nickname}\n${t.occupationLabel}${t.colon}${occupation}\n${t.bioLabel}${t.colon}${bio}\n${t.styleLabel}${t.colon}${styleName}\n${t.traitsLabel}${t.colon}${
|
|
1276
|
+
text: `${t.showTitle}\n\n${t.statusLabel}${t.colon}${status}\n${t.nicknameLabel}${t.colon}${nickname}\n${t.occupationLabel}${t.colon}${occupation}\n${t.bioLabel}${t.colon}${bio}\n${t.styleLabel}${t.colon}${styleName}\n${t.traitsLabel}${t.colon}${traitsLine}\n${t.trailLabel}${t.colon}${trailText}\n${t.instructionsLabel}${t.colon}${instructions}\n${t.toolConfirmLabel}${t.colon}${config.requireToolConfirmation ? t.onLabel : t.offLabel}\n${t.personasLabel}${t.colon}${personaCount}${pendingLine}\n${t.deliveryLabel}${t.colon}${delivery}\n\n${t.help}`
|
|
865
1277
|
}
|
|
866
1278
|
}
|
|
867
1279
|
|
|
868
1280
|
if (first === 'reset') {
|
|
869
|
-
//
|
|
870
|
-
|
|
1281
|
+
// 重置活动配置,但保留人设预设库。
|
|
1282
|
+
// 经由逃生口:配置损坏时这是唯一还能落盘的操作(先备份损坏文件)。
|
|
1283
|
+
const { config: updated, promptChanged, backupPath } = await resetConfigWithEscape()
|
|
871
1284
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
872
|
-
return
|
|
1285
|
+
return backupPath
|
|
1286
|
+
? { kind: 'success', text: t.resetRecovered(backupPath) }
|
|
1287
|
+
: { kind: 'success', text: t.resetDone }
|
|
873
1288
|
}
|
|
874
1289
|
|
|
875
1290
|
if (first === 'enable') {
|
|
@@ -888,6 +1303,9 @@ function registerCommands(ctx) {
|
|
|
888
1303
|
if (first === 'save') {
|
|
889
1304
|
const personaName = sanitizePersonaName(rest)
|
|
890
1305
|
if (!personaName) return { kind: 'error', text: t.saveUsage }
|
|
1306
|
+
if (isBuiltinPersona(personaName)) {
|
|
1307
|
+
return { kind: 'error', text: t.builtinSaveBlocked(personaName) }
|
|
1308
|
+
}
|
|
891
1309
|
await commitConfig((current) => {
|
|
892
1310
|
const personas = clonePersonas(current.personas)
|
|
893
1311
|
personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
|
|
@@ -899,31 +1317,46 @@ function registerCommands(ctx) {
|
|
|
899
1317
|
if (first === 'use') {
|
|
900
1318
|
const personaName = sanitizePersonaName(rest)
|
|
901
1319
|
if (!personaName) return { kind: 'error', text: t.useUsage }
|
|
902
|
-
|
|
1320
|
+
// 内置与用户预设共用同一条应用路径(resolvePersona 先内置后用户)
|
|
1321
|
+
const entry = resolvePersona(personaName, config.personas)
|
|
1322
|
+
if (!entry) {
|
|
903
1323
|
return { kind: 'error', text: t.personaMissing(personaName) }
|
|
904
1324
|
}
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
const {
|
|
1325
|
+
// 预设值保存时已校验;此处再过一遍白名单,防御手改文件等异常数据。
|
|
1326
|
+
// 非法字段不再被静默丢弃:applied=0 ⇒ 报错;有部分非法 ⇒ 明示被跳过的字段。
|
|
1327
|
+
const { changed, invalid, applied } = await applyPersonaEntry(ctx, entry)
|
|
1328
|
+
const fieldsText = invalid.join(t.listSep)
|
|
1329
|
+
// 预设未声明任何字段(applied=0 且 invalid=0)不是错误,是「无可应用内容」
|
|
1330
|
+
if (invalid.length > 0 && applied === 0) {
|
|
1331
|
+
return { kind: 'error', text: t.useAllInvalid(personaName, fieldsText) }
|
|
1332
|
+
}
|
|
1333
|
+
if (invalid.length > 0) {
|
|
1334
|
+
return { kind: 'success', text: t.usePartial(personaName, fieldsText) }
|
|
1335
|
+
}
|
|
908
1336
|
if (changed.length === 0) {
|
|
909
1337
|
return { kind: 'success', text: t.useUnchanged(personaName) }
|
|
910
1338
|
}
|
|
911
|
-
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
912
1339
|
return { kind: 'success', text: t.useDone(personaName) }
|
|
913
1340
|
}
|
|
914
1341
|
|
|
915
1342
|
if (first === 'list') {
|
|
916
|
-
|
|
917
|
-
const
|
|
1343
|
+
// 与 Web UI 一致:列出合并视图(内置 + 用户),并标出内置项
|
|
1344
|
+
const view = mergePersonas(config.personas)
|
|
1345
|
+
const names = Object.keys(view).sort()
|
|
918
1346
|
if (names.length === 0) {
|
|
919
1347
|
return { kind: 'success', text: `${t.listTitle}\n${t.listEmpty}` }
|
|
920
1348
|
}
|
|
921
1349
|
const activeName = findActivePersonaName(config)
|
|
922
1350
|
const lines = names.map((personaName) => {
|
|
923
|
-
const entry =
|
|
1351
|
+
const entry = view[personaName]
|
|
924
1352
|
const styleText = STYLE_VALUES.includes(entry.style) ? (t.styleNames[entry.style] || entry.style) : (entry.style || '-')
|
|
925
|
-
|
|
926
|
-
|
|
1353
|
+
// 原先这里显示 `昵称=${entry.nickname || '-'}`,但 v0.7.1 起预设**不含**
|
|
1354
|
+
// 「关于你」(PERSONA_FIELDS 里没有 nickname,normalizePersonas 也会剥离)
|
|
1355
|
+
// ⇒ 该列恒为「昵称=-」,是一列永远没有信息的死数据。改为报告它实际
|
|
1356
|
+
// 覆盖了几个人格维度:这才是「应用这个预设会改变多少东西」的有效信息。
|
|
1357
|
+
const covers = declaredPersonaKeys(entry).length
|
|
1358
|
+
const mark = entry.builtin ? ` [${t.builtinName}]` : ''
|
|
1359
|
+
return `${personaName === activeName ? '✔ ' : '• '}${personaName}${mark} | ${t.styleLabel}=${styleText} | ${t.listCovers(covers)}`
|
|
927
1360
|
})
|
|
928
1361
|
return { kind: 'success', text: `${t.listTitle} [${names.length}]\n${lines.join('\n')}` }
|
|
929
1362
|
}
|
|
@@ -931,6 +1364,9 @@ function registerCommands(ctx) {
|
|
|
931
1364
|
if (first === 'del' || first === 'delete' || first === 'rm') {
|
|
932
1365
|
const personaName = sanitizePersonaName(rest)
|
|
933
1366
|
if (!personaName) return { kind: 'error', text: t.delUsage }
|
|
1367
|
+
if (isBuiltinPersona(personaName)) {
|
|
1368
|
+
return { kind: 'error', text: t.builtinDeleteBlocked(personaName) }
|
|
1369
|
+
}
|
|
934
1370
|
if (!hasOwn(config.personas || {}, personaName)) {
|
|
935
1371
|
return { kind: 'error', text: t.personaMissing(personaName) }
|
|
936
1372
|
}
|
|
@@ -946,12 +1382,17 @@ function registerCommands(ctx) {
|
|
|
946
1382
|
const pairs = parseSetArgs(rest)
|
|
947
1383
|
if (!pairs || pairs.length === 0) return { kind: 'error', text: t.setUsage }
|
|
948
1384
|
const rawPatch = {}
|
|
949
|
-
for (const [
|
|
1385
|
+
for (const [rawKey, rawValue] of pairs) {
|
|
1386
|
+
// 键名忽略大小写(与子命令关键字一致);值统一 trim(枚举此前不 trim)
|
|
1387
|
+
const key = normalizeSetKey(rawKey)
|
|
1388
|
+
const value = rawValue.trim()
|
|
950
1389
|
if (COMMAND_BOOLEAN_KEYS.has(key)) {
|
|
951
1390
|
const flag = value.toLowerCase()
|
|
952
1391
|
if (['true', '1', 'on', 'yes'].includes(flag)) rawPatch[key] = true
|
|
953
1392
|
else if (['false', '0', 'off', 'no'].includes(flag)) rawPatch[key] = false
|
|
954
|
-
|
|
1393
|
+
// 带上收到的实际值:`enabled=true extra` 会解析成值 "true extra",
|
|
1394
|
+
// 只说「必须为 true / false」会让人以为是关键字写错了
|
|
1395
|
+
else return { kind: 'error', text: t.failed(t.invalidBoolean(key, value)) }
|
|
955
1396
|
} else {
|
|
956
1397
|
rawPatch[key] = value
|
|
957
1398
|
}
|
|
@@ -961,7 +1402,9 @@ function registerCommands(ctx) {
|
|
|
961
1402
|
if (errorKeys.length > 0) {
|
|
962
1403
|
return { kind: 'error', text: t.failed(`${errorKeys[0]}: ${errors[errorKeys[0]]}`) }
|
|
963
1404
|
}
|
|
964
|
-
|
|
1405
|
+
// hasOwn 而非 `in`:后者会命中原型链(`constructor` 之类恒为 true),
|
|
1406
|
+
// 让真正未知的字段被误判成「已识别」而不进 droppedKeys。
|
|
1407
|
+
const droppedKeys = Object.keys(rawPatch).filter((key) => !hasOwn(patch, key))
|
|
965
1408
|
if (Object.keys(patch).length === 0) {
|
|
966
1409
|
return { kind: 'error', text: t.failed(droppedKeys.length > 0 ? `${t.unknownField}: ${droppedKeys.join(', ')}` : t.setUsage) }
|
|
967
1410
|
}
|
|
@@ -1006,21 +1449,33 @@ function registerSystemPrompt(ctx) {
|
|
|
1006
1449
|
let disposeSection = null
|
|
1007
1450
|
|
|
1008
1451
|
const registerSection = () => {
|
|
1009
|
-
// 重新注册前先移除旧 section
|
|
1010
|
-
//
|
|
1452
|
+
// 重新注册前先移除旧 section。
|
|
1453
|
+
//
|
|
1454
|
+
// 注意:这里**不是**靠 `system-prompt/change` 让宿主丢弃快照 —— 该事件当前全树
|
|
1455
|
+
// 没有任何消费者(只在 dsh-system-prompt 内部 emit)。真正让新配置生效的是
|
|
1456
|
+
// section 的 text provider 在每次装配时重新求值(宿主对函数式 text 不做缓存),
|
|
1457
|
+
// 以及活动会话注入这条第二条通道。两件事都有回归兜底:
|
|
1458
|
+
// scripts/verify-live-prompt.mjs / verify-e2e-prompt.mjs。
|
|
1459
|
+
//
|
|
1460
|
+
// 保留「先移除再注册」是因为宿主按名字插入、同名会抛重复错。
|
|
1011
1461
|
disposeSection?.()
|
|
1012
1462
|
|
|
1013
1463
|
disposeSection = spCtx.systemPrompt.section({
|
|
1014
1464
|
name: 'soul:persona',
|
|
1015
1465
|
order: 0,
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1466
|
+
// 关闭宿主的提示词插值(默认开启)。插值是严格模式:文本里每一对完整的
|
|
1467
|
+
// {{...}} 都必须命中一个已注册变量,否则 renderPrompt 抛错;而本 section
|
|
1468
|
+
// 的装配里变量集为空 ⇒ 用户在「自定义指令」里写一句模板语法(例如 {{name}})
|
|
1469
|
+
// 就会让整个 system prompt 装配失败。该抛错位于 agent.step() 开头且无
|
|
1470
|
+
// try/catch ⇒ 不是「忽略那段文字」,而是**该会话的每一轮都失败**。
|
|
1471
|
+
//
|
|
1472
|
+
// 代价为 0:我们不使用任何宿主提示词变量(需要上下文时,text 本身就是
|
|
1473
|
+
// provider)。关掉它同时让「预览所见」与「真实注入」逐字符一致。
|
|
1474
|
+
interpolate: false,
|
|
1475
|
+
text: () => compilePrompt(configCache || DEFAULT_CONFIG)
|
|
1023
1476
|
})
|
|
1477
|
+
|
|
1478
|
+
deliveryState.sectionRegisteredAt = new Date().toISOString()
|
|
1024
1479
|
}
|
|
1025
1480
|
|
|
1026
1481
|
registerSection()
|
|
@@ -1028,12 +1483,12 @@ function registerSystemPrompt(ctx) {
|
|
|
1028
1483
|
globalUpdatePrompt = async () => {
|
|
1029
1484
|
await loadConfig()
|
|
1030
1485
|
registerSection()
|
|
1031
|
-
// console.log('[dsh-soul] 系统提示词已更新,当前会话下一次请求将使用最新配置')
|
|
1032
1486
|
}
|
|
1033
1487
|
|
|
1034
1488
|
ctx.effect(() => () => {
|
|
1035
1489
|
disposeSection?.()
|
|
1036
1490
|
disposeSection = null
|
|
1491
|
+
deliveryState.sectionRegisteredAt = null
|
|
1037
1492
|
if (globalUpdatePrompt) {
|
|
1038
1493
|
globalUpdatePrompt = null
|
|
1039
1494
|
}
|
|
@@ -1054,43 +1509,62 @@ function registerTools(ctx) {
|
|
|
1054
1509
|
}
|
|
1055
1510
|
toolsCtx.tools.register(defineTool({
|
|
1056
1511
|
name: 'set_persona',
|
|
1512
|
+
// 描述是**模型侧**文本,中英并列:工具 schema 只在 apply 时注册一次,而
|
|
1513
|
+
// `tools.register` 对同名重注册会抛 "already registered",语言切换时无法
|
|
1514
|
+
// 原地换描述(registry 只在注销旧句柄后才接受同名)。并列两种语言既覆盖
|
|
1515
|
+
// zh / en 两种界面,又不必引入重注册的时序风险。
|
|
1057
1516
|
description:
|
|
1058
|
-
'
|
|
1517
|
+
'调整当前个性化设置(昵称、回复风格和语调、自定义指令)。' +
|
|
1059
1518
|
'当用户明确要求改变称呼、语气、风格或角色时使用。' +
|
|
1060
|
-
'
|
|
1519
|
+
'只需要传入要修改的字段,未提供的字段保持不变。\n' +
|
|
1520
|
+
'Adjust the current personalization settings (nickname, reply style & tone, custom instructions). ' +
|
|
1521
|
+
'Use it when the user explicitly asks to change how they are addressed, the tone, the style, or the role. ' +
|
|
1522
|
+
'Pass only the fields to change; omitted fields keep their current values.',
|
|
1061
1523
|
parameters: {
|
|
1062
|
-
nickname: { type: 'string', description: '
|
|
1063
|
-
occupation: { type: 'string', description: '
|
|
1064
|
-
bio: { type: 'string', description: '
|
|
1524
|
+
nickname: { type: 'string', description: '用户昵称,回复时会使用这个称呼。/ The nickname to address the user by.' },
|
|
1525
|
+
occupation: { type: 'string', description: '用户的职业。/ The user’s occupation.' },
|
|
1526
|
+
bio: { type: 'string', description: '关于用户的介绍。/ Background about the user.' },
|
|
1065
1527
|
style: {
|
|
1066
1528
|
type: 'string',
|
|
1067
1529
|
enum: STYLE_VALUES,
|
|
1068
|
-
description: '回复风格和语调:professional=专业严谨,casual=轻松自然,humorous=幽默风趣,roast=吐槽达人,efficient
|
|
1530
|
+
description: '回复风格和语调:professional=专业严谨,casual=轻松自然,humorous=幽默风趣,roast=吐槽达人,efficient=高效干练。/ Reply style & tone.'
|
|
1069
1531
|
},
|
|
1070
1532
|
language: {
|
|
1071
1533
|
type: 'string',
|
|
1072
1534
|
enum: LANGUAGE_VALUES,
|
|
1073
|
-
description: '回复语言:zh=简体中文,en=English
|
|
1535
|
+
description: '回复语言:zh=简体中文,en=English。/ Reply language.'
|
|
1074
1536
|
},
|
|
1075
1537
|
headingLists: {
|
|
1076
1538
|
type: 'string',
|
|
1077
1539
|
enum: TRAIT_VALUES,
|
|
1078
|
-
description: '特质·标题和列表:default=默认,more=增强(采用清晰格式和列表结构),less
|
|
1540
|
+
description: '特质·标题和列表:default=默认,more=增强(采用清晰格式和列表结构),less=减弱(使用更多段落文本)。/ Headings & lists.'
|
|
1079
1541
|
},
|
|
1080
1542
|
emoji: {
|
|
1081
1543
|
type: 'string',
|
|
1082
1544
|
enum: TRAIT_VALUES,
|
|
1083
|
-
description: '特质·表情符号:default=默认,more=增强(使用较多表情符号),less
|
|
1545
|
+
description: '特质·表情符号:default=默认,more=增强(使用较多表情符号),less=减弱(尽量减少使用表情符号)。/ Emoji usage.'
|
|
1546
|
+
},
|
|
1547
|
+
tables: {
|
|
1548
|
+
type: 'string',
|
|
1549
|
+
enum: TRAIT_VALUES,
|
|
1550
|
+
description: '特质·表格:default=默认,more=增强(呈现对比、多字段或结构化信息时优先使用表格),less=减弱(避免表格,改用列表或段落文本)。/ Table usage.'
|
|
1551
|
+
},
|
|
1552
|
+
replyLength: {
|
|
1553
|
+
type: 'string',
|
|
1554
|
+
enum: REPLY_LENGTH_VALUES,
|
|
1555
|
+
description: '回复长度偏好:concise=简洁(直击要点、不展开),normal=适中(不额外约束),detailed=详尽(充分展开背景、步骤与推理)。/ Reply length preference.'
|
|
1084
1556
|
},
|
|
1085
|
-
customInstructions: { type: 'string', description: '
|
|
1557
|
+
customInstructions: { type: 'string', description: '额外的自定义指令,用于覆盖或补充当前人设。/ Extra custom instructions that override or extend the current persona.' },
|
|
1086
1558
|
},
|
|
1087
1559
|
output: {
|
|
1088
1560
|
schema: {
|
|
1089
1561
|
type: 'object',
|
|
1090
1562
|
additionalProperties: false,
|
|
1091
|
-
required
|
|
1563
|
+
// 注意:DSH 的 schema DSL **不支持根级 required**(作者错误:
|
|
1564
|
+
// "schema.required is not supported by the value schema DSL")。
|
|
1565
|
+
// 必填只能标在属性上(property.required must be true when present)。
|
|
1092
1566
|
properties: {
|
|
1093
|
-
ok: { type: 'boolean' },
|
|
1567
|
+
ok: { type: 'boolean', required: true },
|
|
1094
1568
|
message: { type: 'string' },
|
|
1095
1569
|
pending: {
|
|
1096
1570
|
type: 'boolean',
|
|
@@ -1099,6 +1573,9 @@ function registerTools(ctx) {
|
|
|
1099
1573
|
changes: {
|
|
1100
1574
|
type: 'object',
|
|
1101
1575
|
description: '实际发生变化的字段',
|
|
1576
|
+
// object 节点必须显式声明 additionalProperties
|
|
1577
|
+
// (作者错误:"...additionalProperties must be explicitly true or false")
|
|
1578
|
+
additionalProperties: false,
|
|
1102
1579
|
properties: {
|
|
1103
1580
|
nickname: { type: 'string' },
|
|
1104
1581
|
occupation: { type: 'string' },
|
|
@@ -1107,6 +1584,8 @@ function registerTools(ctx) {
|
|
|
1107
1584
|
language: { type: 'string' },
|
|
1108
1585
|
headingLists: { type: 'string' },
|
|
1109
1586
|
emoji: { type: 'string' },
|
|
1587
|
+
tables: { type: 'string' },
|
|
1588
|
+
replyLength: { type: 'string' },
|
|
1110
1589
|
customInstructions: { type: 'string' },
|
|
1111
1590
|
},
|
|
1112
1591
|
},
|
|
@@ -1193,7 +1672,10 @@ export async function apply(ctx) {
|
|
|
1193
1672
|
if (Object.keys(errors).length > 0) {
|
|
1194
1673
|
throw new Error('配置校验失败:' + formatFieldErrors(errors))
|
|
1195
1674
|
}
|
|
1196
|
-
const { config } = await commitConfig(current => ({ ...current, ...patch }))
|
|
1675
|
+
const { config, promptChanged } = await commitConfig(current => ({ ...current, ...patch }))
|
|
1676
|
+
// 送达与另外三条写路径保持一致:服务调用方(其它插件)改配置后同样应当
|
|
1677
|
+
// 立即反映到活动会话,否则「改了配置不生效」会从服务这条路再次出现。
|
|
1678
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
|
|
1197
1679
|
return config
|
|
1198
1680
|
},
|
|
1199
1681
|
async getSystemPrompt() {
|
|
@@ -1201,7 +1683,9 @@ export async function apply(ctx) {
|
|
|
1201
1683
|
return compilePrompt(config)
|
|
1202
1684
|
},
|
|
1203
1685
|
async resetConfig() {
|
|
1204
|
-
|
|
1686
|
+
// 与 HTTP / 斜杠命令两条重置入口共用逃生口
|
|
1687
|
+
const { config, promptChanged } = await resetConfigWithEscape()
|
|
1688
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
|
|
1205
1689
|
return config
|
|
1206
1690
|
}
|
|
1207
1691
|
})
|