dsh-soul 0.7.0 → 0.7.2
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 +32 -19
- package/README_EN.md +36 -19
- package/assets/personas/custom-placeholder.webp +0 -0
- package/assets/personas/placeholder.svg +16 -0
- package/assets/personas//344/270/245/346/240/274/344/273/243/347/240/201/345/256/241/351/230/205/350/200/205.webp +0 -0
- package/assets/personas//345/244/264/350/204/221/351/243/216/346/232/264/344/274/231/344/274/264.webp +0 -0
- package/assets/personas//346/225/231/345/255/246/345/236/213/350/256/262/350/247/243/350/200/205.webp +0 -0
- package/assets/personas//346/236/201/347/256/200/344/270/273/344/271/211/350/200/205.webp +0 -0
- package/assets/personas//350/207/252/351/251/261/345/236/213/345/215/217/344/275/234/350/200/205.webp +0 -0
- package/assets/personas//350/213/217/346/240/274/346/213/211/345/272/225/345/274/217/346/217/220/351/227/256/350/200/205.webp +0 -0
- package/assets/personas//350/265/204/346/267/261/346/236/266/346/236/204/345/270/210.webp +0 -0
- package/client/index.mjs +1169 -214
- package/index.mjs +732 -85
- package/lib/config.mjs +42 -6
- package/lib/injection.mjs +2 -2
- package/lib/personas.mjs +62 -25
- package/lib/store.mjs +125 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +13 -2
- package/scripts/lib/skip-report.mjs +73 -0
- package/scripts/verify-client.mjs +1319 -0
- package/scripts/verify-compat.mjs +58 -9
- package/scripts/verify-config.mjs +2116 -44
- package/scripts/verify-e2e-prompt.mjs +627 -0
- package/scripts/verify-live-prompt.mjs +445 -0
- package/scripts/verify-nav-icon.mjs +35 -7
- package/scripts/verify-store.mjs +310 -0
- package/scripts/verify-trail.mjs +21 -8
package/index.mjs
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
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
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
22
23
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
24
|
import {
|
|
24
25
|
DEFAULT_CONFIG,
|
|
@@ -33,7 +34,8 @@ import {
|
|
|
33
34
|
sanitizePersonaName
|
|
34
35
|
} from './lib/config.mjs'
|
|
35
36
|
import { createInjectionSource } from './lib/injection.mjs'
|
|
36
|
-
import { isBuiltinPersona, mergePersonas, personaMatches, resolvePersona } from './lib/personas.mjs'
|
|
37
|
+
import { isBuiltinPersona, builtinPersonaNames, mergePersonas, personaMatches, resolvePersona, declaredPersonaKeys } from './lib/personas.mjs'
|
|
38
|
+
import { configWriteRefusal, describeConfigFailure, moveAsideConfigFile, readConfigFile, writeConfigFile } from './lib/store.mjs'
|
|
37
39
|
|
|
38
40
|
const name = 'soul'
|
|
39
41
|
|
|
@@ -42,6 +44,113 @@ function configPath() {
|
|
|
42
44
|
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'soul-config.json')
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// 版本号:唯一来源是 package.json。
|
|
48
|
+
//
|
|
49
|
+
// 刻意**不**在这里再写一个常量 —— 需要手工同步的版本号已经有三处
|
|
50
|
+
// (package.json / client 的 VERSION / RELEASE_NOTES.md),再加一处只会多一个漂移点。
|
|
51
|
+
// 读不到就返回 null:诊断端点不应该因为版本读不出来而失败。
|
|
52
|
+
function pluginVersion() {
|
|
53
|
+
try {
|
|
54
|
+
return createRequire(import.meta.url)('./package.json').version || null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ==================== 人设插画(方案 C:卡片左侧方图) ====================
|
|
61
|
+
//
|
|
62
|
+
// 图放 assets/personas/<内置人设名>.webp,随包发布;客户端按人设名请求
|
|
63
|
+
// `/api/soul/persona-art/<name>`,宿主读文件返回。三条设计取舍:
|
|
64
|
+
//
|
|
65
|
+
// ① **不进客户端包**:客户端模块由宿主注入,把 7 张图内联成 data URL 会让
|
|
66
|
+
// 客户端包从 114KB 涨到 1MB+;走路由则按需加载、可缓存。
|
|
67
|
+
// ② **缺图回退占位而不是报错**:只有部分人设配了图时,其余显示官方鲸鱼轮廓的
|
|
68
|
+
// 占位图。用户看到的是「一只鲸鱼」而不是破图,且新增人设不必同时补图。
|
|
69
|
+
// ③ **只服务内置人设名**:文件名由人设名构成,必须经白名单校验 —— 否则
|
|
70
|
+
// `/api/soul/persona-art/../../package.json` 这类路径穿越能读到包内任意文件。
|
|
71
|
+
|
|
72
|
+
const PERSONA_ART_DIR = 'assets/personas'
|
|
73
|
+
// 允许的图片扩展名(宿主对插件资源的白名单:SVG / PNG / JPEG / WebP)
|
|
74
|
+
const ART_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.svg']
|
|
75
|
+
// MIME 表:必须显式给出,否则浏览器可能按 octet-stream 下载而不是渲染
|
|
76
|
+
const ART_MIME = {
|
|
77
|
+
'.webp': 'image/webp',
|
|
78
|
+
'.png': 'image/png',
|
|
79
|
+
'.jpg': 'image/jpeg',
|
|
80
|
+
'.jpeg': 'image/jpeg',
|
|
81
|
+
'.svg': 'image/svg+xml'
|
|
82
|
+
}
|
|
83
|
+
const PLACEHOLDER_FILE = 'placeholder.svg'
|
|
84
|
+
// 自定义(自建)预设的默认贴图:鲸鱼剪影 + 装饰元素,用户提供的品牌插画。
|
|
85
|
+
// 与内置插画同为 512×512 WebP、视觉重量一致,但形态是无五官的剪影 ——
|
|
86
|
+
// 内置是有具体人格的具象插画,自建是「还没定制」的中性底图,一眼可区分。
|
|
87
|
+
const CUSTOM_ART_FILE = 'custom-placeholder.webp'
|
|
88
|
+
|
|
89
|
+
// 包根目录下的 assets/personas 绝对路径
|
|
90
|
+
function personaArtDir() {
|
|
91
|
+
try {
|
|
92
|
+
const pkgDir = createRequire(import.meta.url).resolve('./package.json').replace(/[\\/]package\.json$/, '')
|
|
93
|
+
return join(pkgDir, PERSONA_ART_DIR)
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 解析某个内置人设的插画(返回文件名,或回退到占位图;都不可用返回 null)
|
|
100
|
+
//
|
|
101
|
+
// 白名单校验用 isBuiltinPersona:人设名因此只能是既定 7 个之一,不含 / 与 ..,
|
|
102
|
+
// 路径穿越从结构上不可能发生(而不是靠字符串替换去猜)。
|
|
103
|
+
function resolvePersonaArt(name) {
|
|
104
|
+
const dir = personaArtDir()
|
|
105
|
+
if (!dir || typeof name !== 'string') return null
|
|
106
|
+
// 自建预设没有配套插画 ⇒ 用专用默认贴图(本版只做样式调整;
|
|
107
|
+
// 允许用户上传本地图片作为插画是后续版本的功能)。
|
|
108
|
+
//
|
|
109
|
+
// 这里**仍不接受任意名字拼路径**:只有内置名会走下面的按名查找,
|
|
110
|
+
// 非内置名一律返回固定文件,路径穿越依然不可能发生。
|
|
111
|
+
if (!isBuiltinPersona(name)) {
|
|
112
|
+
const custom = join(dir, CUSTOM_ART_FILE)
|
|
113
|
+
// MIME 按扩展名取,不写死 —— 贴图格式变过一次(svg → webp),
|
|
114
|
+
// 写死会让浏览器把它当 octet-stream 下载而不是渲染。
|
|
115
|
+
const ext = CUSTOM_ART_FILE.slice(CUSTOM_ART_FILE.lastIndexOf('.'))
|
|
116
|
+
return existsSync(custom) ? { file: custom, mime: ART_MIME[ext] } : null
|
|
117
|
+
}
|
|
118
|
+
for (const ext of ART_EXTENSIONS) {
|
|
119
|
+
const file = join(dir, `${name}${ext}`)
|
|
120
|
+
if (existsSync(file)) return { file, mime: ART_MIME[ext] }
|
|
121
|
+
}
|
|
122
|
+
const fallback = join(dir, PLACEHOLDER_FILE)
|
|
123
|
+
if (existsSync(fallback)) return { file: fallback, mime: ART_MIME['.svg'] }
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 已配图的人设名列表(诊断端点用:一眼看出哪几个还缺图)
|
|
128
|
+
function personasWithArt() {
|
|
129
|
+
const dir = personaArtDir()
|
|
130
|
+
if (!dir || !existsSync(dir)) return []
|
|
131
|
+
let files = []
|
|
132
|
+
try {
|
|
133
|
+
files = readdirSync(dir)
|
|
134
|
+
} catch {
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
const out = []
|
|
138
|
+
for (const name of builtinPersonaNamesSafe()) {
|
|
139
|
+
if (ART_EXTENSIONS.some((ext) => files.includes(`${name}${ext}`))) out.push(name)
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// builtinPersonaNames 的容错包装:诊断路径不应因为导入异常而失败
|
|
145
|
+
function builtinPersonaNamesSafe() {
|
|
146
|
+
try {
|
|
147
|
+
return builtinPersonaNames()
|
|
148
|
+
} catch {
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
45
154
|
// ==================== 提示词文案与构建器 ====================
|
|
46
155
|
|
|
47
156
|
// 提示词固定文案表:所有进入 system prompt / 会话注入 / 工具返回的文本,
|
|
@@ -100,6 +209,8 @@ const PROMPT_TEXT = {
|
|
|
100
209
|
toolNoChanges: '没有要更改的设置',
|
|
101
210
|
toolValidationFailed: '配置校验失败:',
|
|
102
211
|
toolProposal: '已收到人设变更请求(确认模式已开启,尚未生效):',
|
|
212
|
+
toolRejected: '你在审批面板中拒绝了这次人设变更,未做任何修改。',
|
|
213
|
+
toolApprovalDisabled: '说明:本次会话的审批通道已被禁用(approval policy = never),因此没有弹出确认面板;这不代表你做了拒绝操作。',
|
|
103
214
|
toolProposalHint: '在用户确认前不会生效;用户可用 /soul confirm 确认,或 /soul reject 拒绝。'
|
|
104
215
|
},
|
|
105
216
|
en: {
|
|
@@ -155,6 +266,8 @@ const PROMPT_TEXT = {
|
|
|
155
266
|
toolNoChanges: 'No settings to change',
|
|
156
267
|
toolValidationFailed: 'Config validation failed: ',
|
|
157
268
|
toolProposal: 'Persona change requested (confirmation mode is on — not applied yet):',
|
|
269
|
+
toolRejected: 'You rejected this persona change in the approval panel — nothing was modified.',
|
|
270
|
+
toolApprovalDisabled: 'Note: the approval channel is disabled in this session (approval policy = never), so no confirmation panel appeared; this does not mean you rejected anything.',
|
|
158
271
|
toolProposalHint: 'It stays pending until the user confirms with /soul confirm or rejects with /soul reject.'
|
|
159
272
|
}
|
|
160
273
|
}
|
|
@@ -198,29 +311,48 @@ const MAX_BODY_BYTES = 64 * 1024
|
|
|
198
311
|
// 内存中的配置缓存
|
|
199
312
|
let configCache = null
|
|
200
313
|
|
|
201
|
-
//
|
|
314
|
+
// 磁盘上的配置存在但不可用(读不出来 / 不是合法 JSON)时的记录。
|
|
315
|
+
// 置位后 saveConfig 会拒绝写入,避免把损坏的配置静默清空成默认值。
|
|
316
|
+
let configLoadFailure = null
|
|
317
|
+
|
|
318
|
+
// 读取配置。
|
|
319
|
+
//
|
|
320
|
+
// 文件不存在(首次运行)与文件损坏是两件不同的事,必须分开处理:
|
|
321
|
+
// - 不存在 → 回退默认值,正常路径,可以照常保存;
|
|
322
|
+
// - 损坏 → 也回退默认值(保证插件与设置页仍可用),但记录失败原因,
|
|
323
|
+
// 并让写入路径拒绝保存。否则「回退默认」会在下一次保存时变成
|
|
324
|
+
// 「以默认值为底写回磁盘」,用户的自定义指令与预设库就永久丢了。
|
|
325
|
+
//
|
|
326
|
+
// 失败时刻意**不写 configCache**:这样用户修好文件后,下一次读取会自动恢复正常,
|
|
327
|
+
// 不必重启 DSH。
|
|
202
328
|
async function loadConfig() {
|
|
203
329
|
if (configCache) return configCache
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
330
|
+
configLoadFailure = null
|
|
331
|
+
|
|
332
|
+
const { value, exists, failure } = await readConfigFile(configPath())
|
|
333
|
+
if (failure) {
|
|
334
|
+
configLoadFailure = failure
|
|
335
|
+
return { ...DEFAULT_CONFIG }
|
|
210
336
|
}
|
|
337
|
+
|
|
338
|
+
configCache = exists ? migrateConfig(value) : { ...DEFAULT_CONFIG }
|
|
211
339
|
return configCache
|
|
212
340
|
}
|
|
213
341
|
|
|
214
|
-
//
|
|
342
|
+
// 保存配置(原子写,见 lib/store.mjs 的 writeConfigFile)。
|
|
343
|
+
// 磁盘配置损坏时拒绝写入:拒绝的原因与恢复方式由 lib/store.mjs 统一生成。
|
|
215
344
|
async function saveConfig(config) {
|
|
216
|
-
const
|
|
217
|
-
|
|
345
|
+
const refusal = configWriteRefusal(configLoadFailure, configPath())
|
|
346
|
+
if (refusal) {
|
|
347
|
+
throw new Error(refusal)
|
|
348
|
+
}
|
|
349
|
+
|
|
218
350
|
// 过滤已废弃字段(旧客户端可能仍携带 tone / presets / examples)
|
|
219
351
|
const clean = { ...config }
|
|
220
352
|
delete clean.tone
|
|
221
353
|
delete clean.presets
|
|
222
354
|
delete clean.examples
|
|
223
|
-
await
|
|
355
|
+
await writeConfigFile(configPath(), clean)
|
|
224
356
|
configCache = clean
|
|
225
357
|
return clean
|
|
226
358
|
}
|
|
@@ -277,6 +409,31 @@ function defaultConfigPreservingPersonas(current) {
|
|
|
277
409
|
return next
|
|
278
410
|
}
|
|
279
411
|
|
|
412
|
+
// 重置:配置损坏时的**逃生口**。
|
|
413
|
+
//
|
|
414
|
+
// 损坏状态下所有写入都被 saveConfig 拒绝 —— 包括重置。但「一个出口都没有」意味着
|
|
415
|
+
// 用户只能自己去删文件,所以「重置」被定义为**刻意丢弃**:先把损坏的文件移开
|
|
416
|
+
// (=备份到 <config>.corrupt),路径随即回到「首次运行」分支,这次写入才能真正落盘。
|
|
417
|
+
//
|
|
418
|
+
// 这与 R3 的「损坏配置绝不**静默**被覆盖」不冲突:静默覆盖被禁止,而重置是用户显式
|
|
419
|
+
// 发起的动作、且覆盖前先留下备份。
|
|
420
|
+
//
|
|
421
|
+
// 复用 commitConfig 的「读—改—写」,不另写一份;写入失败时恢复失败标记,
|
|
422
|
+
// 避免把「文件仍然不可用」误报成正常状态。
|
|
423
|
+
async function resetConfigWithEscape() {
|
|
424
|
+
const failure = configLoadFailure
|
|
425
|
+
const backupPath = failure ? await moveAsideConfigFile(configPath()) : null
|
|
426
|
+
if (failure) configLoadFailure = null
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const result = await commitConfig((current) => defaultConfigPreservingPersonas(current))
|
|
430
|
+
return { ...result, backupPath }
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (failure) configLoadFailure = failure
|
|
433
|
+
throw err
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
280
437
|
// 将字段级校验错误汇总为一行文本(HTTP 错误信息 / 工具返回消息共用)
|
|
281
438
|
function formatFieldErrors(errors) {
|
|
282
439
|
return Object.entries(errors)
|
|
@@ -351,14 +508,81 @@ function compilePrompt(config) {
|
|
|
351
508
|
return parts.join('\n')
|
|
352
509
|
}
|
|
353
510
|
|
|
354
|
-
// ====================
|
|
511
|
+
// ==================== 配置生效链路的送达诊断 ====================
|
|
512
|
+
|
|
513
|
+
// 「改配置 → 下一轮生效」由两条通道共同承载:system prompt section(底座)与
|
|
514
|
+
// 活动会话注入(立即见效)。任一条静默失效,表面症状都是「改了人设不生效」而没有
|
|
515
|
+
// 任何报错 —— 正是本插件历史上最难排查的一类问题(见 DEBUGGING.md 3.5)。
|
|
516
|
+
//
|
|
517
|
+
// 因此这里如实记录事实,并让三处可读到:日志(同因只告一次)、GET /api/soul/status、
|
|
518
|
+
// /soul show。诊断本身不改变行为,只在真的出问题时才说话(正常状态不打扰)。
|
|
519
|
+
const deliveryState = {
|
|
520
|
+
injectedAt: null, // 最近一次注入尝试的时间(ISO)
|
|
521
|
+
agents: 0, // 最近一次看到的活动会话数
|
|
522
|
+
delivered: 0, // 最近一次成功注入的会话数
|
|
523
|
+
failed: 0, // 最近一次注入抛错的会话数
|
|
524
|
+
injectionProblem: null, // 未能如实送达的原因;正常为 null
|
|
525
|
+
sectionRegisteredAt: null // section 最近一次注册成功的时间(ISO)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 同一原因只告一次警,避免每次保存都刷屏;一旦恢复正常则清掉记录,
|
|
529
|
+
// 这样「好了又坏」会再次告警。
|
|
530
|
+
const warnedReasons = new Set()
|
|
355
531
|
|
|
356
|
-
|
|
532
|
+
function warnOnce(ctx, reason, message) {
|
|
533
|
+
if (warnedReasons.has(reason)) return
|
|
534
|
+
warnedReasons.add(reason)
|
|
535
|
+
try {
|
|
536
|
+
ctx.logger?.warn?.(`[dsh-soul] ${message}`)
|
|
537
|
+
} catch {
|
|
538
|
+
// 日志不可用不应影响功能
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 把内部原因码翻译成一句能直接看懂的话(/soul show、设置页、状态端点共用)。
|
|
543
|
+
//
|
|
544
|
+
// 文案取自 **COMMAND_MESSAGES**(不是 PROMPT_TEXT):delivery* 系列键只定义在命令文案表里,
|
|
545
|
+
// 两者都按 config.language 提供中英两套。这里此前误用 promptTextOf ⇒ 该函数在
|
|
546
|
+
// 'agents-service-unavailable' 分支返回 undefined(JSON 序列化时字段整个消失,故障对
|
|
547
|
+
// 用户不可见),在 'inject-failed' 分支直接抛 TypeError(保存已落盘却报 HTTP 500)。
|
|
548
|
+
function deliveryProblemText(config) {
|
|
549
|
+
const problem = deliveryState.injectionProblem
|
|
550
|
+
if (!problem) return null
|
|
551
|
+
const T = commandMessages(config)
|
|
552
|
+
if (problem === 'agents-service-unavailable') return T.deliveryNoAgentsService
|
|
553
|
+
return T.deliveryInjectFailed(problem.replace(/^inject-failed:\s*/, ''))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 把最新配置作为 model-facing context 注入所有活动会话。
|
|
357
557
|
// inject() 不会唤醒 Agent,也不改写普通用户消息;会在下一次 step 被模型读取。
|
|
358
558
|
function injectPromptToAllAgents(ctx, config) {
|
|
559
|
+
const stamp = new Date().toISOString()
|
|
560
|
+
const unavailable = (reason, message) => {
|
|
561
|
+
deliveryState.injectedAt = stamp
|
|
562
|
+
deliveryState.agents = 0
|
|
563
|
+
deliveryState.delivered = 0
|
|
564
|
+
deliveryState.failed = 0
|
|
565
|
+
deliveryState.injectionProblem = reason
|
|
566
|
+
warnOnce(ctx, reason, message)
|
|
567
|
+
}
|
|
568
|
+
|
|
359
569
|
const agents = ctx.get('agents')
|
|
360
570
|
if (!agents || typeof agents.list !== 'function') {
|
|
361
|
-
|
|
571
|
+
unavailable(
|
|
572
|
+
'agents-service-unavailable',
|
|
573
|
+
'agents 服务不可用:新配置只能经 system prompt section 送达,保存后可能要到新会话才生效'
|
|
574
|
+
)
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let listed
|
|
579
|
+
try {
|
|
580
|
+
listed = agents.list()
|
|
581
|
+
} catch (err) {
|
|
582
|
+
unavailable(
|
|
583
|
+
'agents-service-unavailable',
|
|
584
|
+
`列出活动会话失败(${String((err && err.message) || err)}):新配置只能经 system prompt section 送达`
|
|
585
|
+
)
|
|
362
586
|
return
|
|
363
587
|
}
|
|
364
588
|
|
|
@@ -366,7 +590,11 @@ function injectPromptToAllAgents(ctx, config) {
|
|
|
366
590
|
const prompt = compilePrompt(config)
|
|
367
591
|
const snapshotText = prompt || T.injectDisabled
|
|
368
592
|
|
|
369
|
-
|
|
593
|
+
let delivered = 0
|
|
594
|
+
let failed = 0
|
|
595
|
+
let lastError = null
|
|
596
|
+
|
|
597
|
+
for (const agent of listed) {
|
|
370
598
|
try {
|
|
371
599
|
agent.inject(createUserMessage({
|
|
372
600
|
content: [{
|
|
@@ -377,17 +605,39 @@ function injectPromptToAllAgents(ctx, config) {
|
|
|
377
605
|
// 会让每一轮在 step 开始前失败(issue #1,构造与校验见 lib/injection.mjs)
|
|
378
606
|
source: createInjectionSource(snapshotText)
|
|
379
607
|
}))
|
|
380
|
-
|
|
608
|
+
delivered++
|
|
381
609
|
} catch (err) {
|
|
382
|
-
|
|
610
|
+
failed++
|
|
611
|
+
lastError = err
|
|
612
|
+
try {
|
|
613
|
+
ctx.logger?.warn?.(`[dsh-soul] 向会话 ${agent?.id} 注入配置失败:${String((err && err.message) || err)}`)
|
|
614
|
+
} catch {
|
|
615
|
+
// 同上:日志失败不影响功能
|
|
616
|
+
}
|
|
383
617
|
}
|
|
384
618
|
}
|
|
619
|
+
|
|
620
|
+
deliveryState.injectedAt = stamp
|
|
621
|
+
deliveryState.agents = listed.length
|
|
622
|
+
deliveryState.delivered = delivered
|
|
623
|
+
deliveryState.failed = failed
|
|
624
|
+
deliveryState.injectionProblem = failed > 0
|
|
625
|
+
? `inject-failed: ${String((lastError && lastError.message) || lastError)}`
|
|
626
|
+
: null
|
|
627
|
+
|
|
628
|
+
// 恢复正常后允许再次告警(「好了又坏」不该被静默)
|
|
629
|
+
if (failed === 0) warnedReasons.delete('agents-service-unavailable')
|
|
630
|
+
else warnedReasons.add('agents-service-unavailable')
|
|
385
631
|
}
|
|
386
632
|
|
|
387
633
|
function refreshPromptAndInject(ctx, config) {
|
|
388
634
|
if (globalUpdatePrompt) {
|
|
389
635
|
globalUpdatePrompt().catch(err => {
|
|
390
|
-
|
|
636
|
+
try {
|
|
637
|
+
ctx.logger?.warn?.(`[dsh-soul] 重新注册 system prompt section 失败:${String((err && err.message) || err)}`)
|
|
638
|
+
} catch {
|
|
639
|
+
// 同上
|
|
640
|
+
}
|
|
391
641
|
})
|
|
392
642
|
}
|
|
393
643
|
injectPromptToAllAgents(ctx, config)
|
|
@@ -410,7 +660,18 @@ function registerRoutes(ctx) {
|
|
|
410
660
|
try {
|
|
411
661
|
if (req.method === 'GET') {
|
|
412
662
|
const config = await loadConfig()
|
|
413
|
-
|
|
663
|
+
// configError:磁盘配置存在但不可用时上报(此时 config 是回退的默认值)。
|
|
664
|
+
// 让设置页能直接说明「人设为什么像是被重置了」,而不是让用户自己发现;
|
|
665
|
+
// 正常情况下该字段为 null,不影响既有客户端。
|
|
666
|
+
send(200, {
|
|
667
|
+
ok: true,
|
|
668
|
+
config,
|
|
669
|
+
configError: configLoadFailure ? describeConfigFailure(configLoadFailure) : null,
|
|
670
|
+
// deliveryWarning:配置送达异常时上报(例如 agents 服务不可用)。
|
|
671
|
+
// 与 configError 分开:configError 是「配置读不出来」,这里是「配置送不到会话」,
|
|
672
|
+
// 两者症状都表现为「改了人设不生效」,但处理方式完全不同。
|
|
673
|
+
deliveryWarning: deliveryProblemText(config)
|
|
674
|
+
})
|
|
414
675
|
} else if (req.method === 'POST') {
|
|
415
676
|
const parsed = await readJsonBody(req)
|
|
416
677
|
if (!parsed.ok) {
|
|
@@ -434,7 +695,7 @@ function registerRoutes(ctx) {
|
|
|
434
695
|
refreshPromptAndInject(ctx, updated)
|
|
435
696
|
}
|
|
436
697
|
|
|
437
|
-
send(200, { ok: true, config: updated, changed })
|
|
698
|
+
send(200, { ok: true, config: updated, changed, deliveryWarning: deliveryProblemText(updated) })
|
|
438
699
|
} else {
|
|
439
700
|
send(405, { ok: false, error: 'Method not allowed' })
|
|
440
701
|
}
|
|
@@ -455,6 +716,13 @@ function registerRoutes(ctx) {
|
|
|
455
716
|
}
|
|
456
717
|
|
|
457
718
|
try {
|
|
719
|
+
// 方法校验:本端点只读。此前是唯一不判 method 的端点(同文件其余 7 处都有
|
|
720
|
+
// 405 分支),实测 POST /api/soul/prompt 会拿到 200 —— 宿主只做路径匹配,
|
|
721
|
+
// method 判定确实是 handler 的责任。
|
|
722
|
+
if (req.method !== 'GET') {
|
|
723
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
724
|
+
return
|
|
725
|
+
}
|
|
458
726
|
const config = await loadConfig()
|
|
459
727
|
const prompt = compilePrompt(config)
|
|
460
728
|
send(200, { ok: true, prompt, enabled: config.enabled })
|
|
@@ -519,15 +787,77 @@ function registerRoutes(ctx) {
|
|
|
519
787
|
res.end(JSON.stringify(body))
|
|
520
788
|
}
|
|
521
789
|
|
|
790
|
+
// 方法校验:这是**破坏性写**入口(重置配置、必要时丢弃并备份损坏文件),
|
|
791
|
+
// 缺了它任何 HTTP 方法都能触发 —— 而 GET 是浏览器预取、爬虫、<img src>、
|
|
792
|
+
// 链接预览都会发出的方法,一个 GET 就能重置用户配置。
|
|
793
|
+
// 同期的 personas/save、personas/delete 都有此校验,此处属遗漏。
|
|
794
|
+
if (req.method !== 'POST') {
|
|
795
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
|
|
522
799
|
try {
|
|
523
|
-
|
|
800
|
+
// 经由逃生口:配置损坏时这是设置页唯一还能成功的写操作。
|
|
801
|
+
// backupPath 非 null 说明刚才确实丢弃了一份损坏的配置(已备份),
|
|
802
|
+
// 客户端据此给出「备份在哪」的提示,而不是让用户以为数据凭空消失。
|
|
803
|
+
const { config, changed, promptChanged, backupPath } = await resetConfigWithEscape()
|
|
524
804
|
|
|
525
805
|
// 仅影响 Agent 行为的字段变化才刷新提示词并注入会话
|
|
526
806
|
if (promptChanged.length > 0) {
|
|
527
807
|
refreshPromptAndInject(ctx, config)
|
|
528
808
|
}
|
|
529
809
|
|
|
530
|
-
send(200, { ok: true, config, changed })
|
|
810
|
+
send(200, { ok: true, config, changed, backupPath, deliveryWarning: deliveryProblemText(config) })
|
|
811
|
+
} catch (err) {
|
|
812
|
+
send(500, { ok: false, error: String(err.message || err) })
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
// 生效链路诊断:两条通道(system prompt section / 活动会话注入)的当前状态。
|
|
818
|
+
// 设置页与 /soul show 之外,这是给「改了配置不生效」提供的第三个、也是脚本化的观察点。
|
|
819
|
+
wsCtx.webServer.register({
|
|
820
|
+
kind: 'exact',
|
|
821
|
+
path: '/api/soul/status',
|
|
822
|
+
handler: async (req, res) => {
|
|
823
|
+
const send = (status, body) => {
|
|
824
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
825
|
+
res.end(JSON.stringify(body))
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
try {
|
|
829
|
+
if (req.method !== 'GET') {
|
|
830
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const config = await loadConfig()
|
|
835
|
+
const injection = {
|
|
836
|
+
lastAt: deliveryState.injectedAt,
|
|
837
|
+
agents: deliveryState.agents,
|
|
838
|
+
delivered: deliveryState.delivered,
|
|
839
|
+
failed: deliveryState.failed,
|
|
840
|
+
problem: deliveryState.injectionProblem
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
send(200, {
|
|
844
|
+
ok: true,
|
|
845
|
+
version: pluginVersion(),
|
|
846
|
+
enabled: config.enabled,
|
|
847
|
+
configPath: configPath(),
|
|
848
|
+
configError: configLoadFailure ? describeConfigFailure(configLoadFailure) : null,
|
|
849
|
+
promptChars: compilePrompt(config).length,
|
|
850
|
+
channels: {
|
|
851
|
+
// section 是底座:它一旦注册成功,宿主每一步装配都会重新求值
|
|
852
|
+
// (不依赖任何缓存,见 scripts/verify-live-prompt.mjs)
|
|
853
|
+
section: {
|
|
854
|
+
registered: deliveryState.sectionRegisteredAt !== null,
|
|
855
|
+
registeredAt: deliveryState.sectionRegisteredAt
|
|
856
|
+
},
|
|
857
|
+
injection
|
|
858
|
+
},
|
|
859
|
+
deliveryWarning: deliveryProblemText(config)
|
|
860
|
+
})
|
|
531
861
|
} catch (err) {
|
|
532
862
|
send(500, { ok: false, error: String(err.message || err) })
|
|
533
863
|
}
|
|
@@ -543,14 +873,21 @@ function registerRoutes(ctx) {
|
|
|
543
873
|
// 待确认的人设变更提议(set_persona 确认模式;进程内短生命周期,不做持久化)
|
|
544
874
|
let pendingPersonaProposal = null
|
|
545
875
|
|
|
546
|
-
//
|
|
876
|
+
// 从配置中提取人设快照(作为预设保存的内容)。
|
|
877
|
+
//
|
|
878
|
+
// 只遍历 PERSONA_FIELDS ⇒ **不含「关于你」(昵称 / 职业 / 介绍)**:预设是 Agent 的
|
|
879
|
+
// 人格存档,不是使用者身份存档。切换预设不该把你的昵称职业一起换掉。
|
|
880
|
+
// 输出语言在范围内(它决定 Agent 用什么语言作答)。
|
|
547
881
|
function personaSnapshotOf(config) {
|
|
548
882
|
const values = {}
|
|
549
883
|
for (const key of PERSONA_FIELDS) values[key] = config[key]
|
|
550
884
|
return values
|
|
551
885
|
}
|
|
552
886
|
|
|
553
|
-
// 从预设条目中挑出人设字段(忽略 updatedAt
|
|
887
|
+
// 从预设条目中挑出人设字段(忽略 updatedAt 等元数据与「关于你」)。
|
|
888
|
+
// `if (key in persona)` 即「部分覆盖」语义:只应用条目**实际声明**的键,
|
|
889
|
+
// 未声明的保持用户当前值;而遍历白名单本身则是第二道防线——即便磁盘上残留了
|
|
890
|
+
// nickname / occupation / bio(历史数据),也不会被写回活动配置。
|
|
554
891
|
function pickPersonaValues(persona) {
|
|
555
892
|
const values = {}
|
|
556
893
|
for (const key of PERSONA_FIELDS) {
|
|
@@ -580,6 +917,27 @@ function findActivePersonaName(config) {
|
|
|
580
917
|
return null
|
|
581
918
|
}
|
|
582
919
|
|
|
920
|
+
// 应用一个预设条目:过白名单 → 写队列合并。
|
|
921
|
+
//
|
|
922
|
+
// **必须回报被丢弃的字段**:normalizePersonas 只按字段名过滤、不校验取值,所以磁盘上
|
|
923
|
+
// 残留的历史值(例如 v0.1.x 的合法 style 'friendly')或手改的异常值会走到这里被
|
|
924
|
+
// sanitizeConfig 拒绝。此前两条应用路径都只取 patch、把 errors 丢掉 ⇒ 预设被「部分
|
|
925
|
+
// 应用」却返回 ok:true / 「已应用」,用户以为整套生效了(实测:style 与 emoji 被静默
|
|
926
|
+
// 丢弃,只有 customInstructions 写回)。
|
|
927
|
+
//
|
|
928
|
+
// 语义选择:应用**合法部分**并如实回报被丢弃的字段,而不是整单拒绝 —— 后者会让一个
|
|
929
|
+
// 只差一个字段的旧预设彻底不可用,代价更高;而「部分应用 + 明示」与 /api/soul/prompt/
|
|
930
|
+
// preview 的 invalid 约定一致。
|
|
931
|
+
async function applyPersonaEntry(ctx, entry) {
|
|
932
|
+
const { patch, errors } = sanitizeConfig(pickPersonaValues(entry))
|
|
933
|
+
const invalid = Object.keys(errors)
|
|
934
|
+
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
|
|
935
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
936
|
+
// applied 是「通过校验的字段数」——与 changed(实际产生差异的字段)不同:
|
|
937
|
+
// 全部非法时 applied 为 0,此时不能报「已应用」,也不能报「与当前配置一致」。
|
|
938
|
+
return { updated, changed, promptChanged, invalid, applied: Object.keys(patch).length }
|
|
939
|
+
}
|
|
940
|
+
|
|
583
941
|
// 预设库的对外视图(内置 + 用户)与当前命中项,供三个路由的响应复用。
|
|
584
942
|
function personaLibrary(config) {
|
|
585
943
|
return { personas: mergePersonas(config.personas), activeName: findActivePersonaName(config) }
|
|
@@ -676,12 +1034,14 @@ function registerPersonaRoutes(ctx) {
|
|
|
676
1034
|
}
|
|
677
1035
|
// 内置预设已在代码里写过一遍;用户预设保存时也校验过。此处统一再过一遍
|
|
678
1036
|
// 白名单,防御手改 config.json 注入的异常数据。
|
|
679
|
-
|
|
680
|
-
const {
|
|
681
|
-
|
|
682
|
-
|
|
1037
|
+
// invalid = 因取值非法而被丢弃的字段名:必须回报,不能静默部分应用。
|
|
1038
|
+
const { updated, changed, invalid, applied } = await applyPersonaEntry(ctx, entry)
|
|
1039
|
+
// 一个合法字段都没有 ⇒ 什么都没应用,按失败处理(与 /soul use 同语义)
|
|
1040
|
+
if (applied === 0) {
|
|
1041
|
+
send(res, 400, { ok: false, error: `预设「${personaName}」没有可应用的合法字段`, invalid })
|
|
1042
|
+
return
|
|
683
1043
|
}
|
|
684
|
-
send(res, 200, { ok: true, config: updated, changed, unchanged: changed.length === 0 })
|
|
1044
|
+
send(res, 200, { ok: true, config: updated, changed, unchanged: changed.length === 0, invalid })
|
|
685
1045
|
} catch (err) {
|
|
686
1046
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
687
1047
|
}
|
|
@@ -727,6 +1087,45 @@ function registerPersonaRoutes(ctx) {
|
|
|
727
1087
|
}
|
|
728
1088
|
}
|
|
729
1089
|
})
|
|
1090
|
+
|
|
1091
|
+
// 人设插画:/api/soul/persona-art/<内置人设名>
|
|
1092
|
+
//
|
|
1093
|
+
// 用 prefix 路由(WebRouteKind 支持 'exact' | 'prefix')而不是 query 参数:
|
|
1094
|
+
// 路径里直接带人设名,浏览器可对每个 URL 独立缓存与复用连接。
|
|
1095
|
+
// 名字必须命中内置人设白名单(isBuiltinPersona),路径穿越因此不可能发生。
|
|
1096
|
+
wsCtx.webServer.register({
|
|
1097
|
+
kind: 'prefix',
|
|
1098
|
+
path: '/api/soul/persona-art',
|
|
1099
|
+
handler: async (req, res) => {
|
|
1100
|
+
try {
|
|
1101
|
+
if (req.method !== 'GET') {
|
|
1102
|
+
res.writeHead(405, { 'content-type': 'application/json' })
|
|
1103
|
+
res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }))
|
|
1104
|
+
return
|
|
1105
|
+
}
|
|
1106
|
+
const raw = String(req.url || '').split('?')[0]
|
|
1107
|
+
const name = decodeURIComponent(raw.slice('/api/soul/persona-art'.length).replace(/^\/+/, ''))
|
|
1108
|
+
const art = resolvePersonaArt(name)
|
|
1109
|
+
if (!art) {
|
|
1110
|
+
// 图与占位图都没有(理论上只会在 assets 被外部删空时出现)
|
|
1111
|
+
res.writeHead(404, { 'content-type': 'application/json' })
|
|
1112
|
+
res.end(JSON.stringify({ ok: false, error: `没有可用的插画:${name || '(空)'}` }))
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
const body = readFileSync(art.file)
|
|
1116
|
+
res.writeHead(200, {
|
|
1117
|
+
'content-type': art.mime,
|
|
1118
|
+
'content-length': String(body.length),
|
|
1119
|
+
// 图随包发布、路径即内容标识 ⇒ 可长期缓存;换图必然伴随版本号变化
|
|
1120
|
+
'cache-control': 'public, max-age=86400'
|
|
1121
|
+
})
|
|
1122
|
+
res.end(body)
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
1125
|
+
res.end(JSON.stringify({ ok: false, error: String(err.message || err) }))
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
730
1129
|
})
|
|
731
1130
|
}
|
|
732
1131
|
|
|
@@ -786,7 +1185,9 @@ const COMMAND_MESSAGES = {
|
|
|
786
1185
|
onLabel: '开启',
|
|
787
1186
|
offLabel: '关闭',
|
|
788
1187
|
personasLabel: '人设预设',
|
|
789
|
-
|
|
1188
|
+
// 提议是**内存态**(重启即失效)、也没有 TTL,所以必须让用户看到它是什么时候提出的,
|
|
1189
|
+
// 否则一条陈旧提议会一直挂着而无法判断新鲜度(proposedAt 此前只写不读)。
|
|
1190
|
+
pendingHint: (at) => `⚠ 有待确认的人设变更(提出于 ${at}):/soul confirm 确认,/soul reject 拒绝`,
|
|
790
1191
|
noPending: '没有待确认的人设变更',
|
|
791
1192
|
confirmDone: (detail) => `✅ 已应用人设变更:${detail}`,
|
|
792
1193
|
rejectDone: '✅ 已拒绝待确认的人设变更',
|
|
@@ -797,20 +1198,32 @@ const COMMAND_MESSAGES = {
|
|
|
797
1198
|
builtinDeleteBlocked: (name) => `「${name}」是内置预设,无法删除`,
|
|
798
1199
|
useDone: (name) => `✅ 已应用预设「${name}」`,
|
|
799
1200
|
useUnchanged: (name) => `ℹ 预设「${name}」与当前配置一致,无需变更`,
|
|
1201
|
+
// 预设里部分字段的取值非法(历史值或手改):合法字段照常应用,被跳过的必须说出来
|
|
1202
|
+
usePartial: (name, fields) => `⚠ 预设「${name}」已部分应用,以下字段取值非法、已跳过:${fields}`,
|
|
1203
|
+
useAllInvalid: (name, fields) => `✗ 预设「${name}」没有可应用的合法字段:${fields}`,
|
|
800
1204
|
personaMissing: (name) => `预设「${name}」不存在`,
|
|
801
1205
|
delDone: (name) => `✅ 已删除预设「${name}」`,
|
|
802
1206
|
listTitle: '📋 人设预设',
|
|
803
1207
|
listEmpty: '暂无人设预设,使用 /soul save <名称> 保存当前配置',
|
|
1208
|
+
// 列表行报告预设实际覆盖的人格维度数量(取代 v0.7.1 起恒为空的「昵称」列)
|
|
1209
|
+
listCovers: (n) => `覆盖 ${n} 项`,
|
|
804
1210
|
saveUsage: '用法:/soul save <名称>(1-30 个字符)',
|
|
805
1211
|
useUsage: '用法:/soul use <名称>',
|
|
806
1212
|
delUsage: '用法:/soul del <名称>',
|
|
807
1213
|
setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
808
1214
|
unknownField: '未知配置项',
|
|
809
|
-
invalidBoolean: (key) => `${key} 取值必须为 true / false
|
|
1215
|
+
invalidBoolean: (key, value) => `${key} 取值必须为 true / false(收到:${JSON.stringify(value)})`,
|
|
810
1216
|
setNoChanges: '配置无变化,未做修改',
|
|
811
1217
|
setDone: (keys) => `✅ 已更新:${keys.join('、')}`,
|
|
812
1218
|
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 设置昵称',
|
|
813
1219
|
resetDone: '✅ 配置已重置为默认值',
|
|
1220
|
+
resetRecovered: (file) => `✅ 配置已重置为默认值(损坏的文件已备份为 ${file})`,
|
|
1221
|
+
deliveryLabel: '送达:',
|
|
1222
|
+
deliveryOk: (delivered, total) => total === 0
|
|
1223
|
+
? '暂无活动会话(配置将在新会话生效)'
|
|
1224
|
+
: `已注入 ${delivered}/${total} 个活动会话`,
|
|
1225
|
+
deliveryNoAgentsService: 'agents 服务不可用 —— 新配置只能经 system prompt section 送达,保存后可能要到新会话才生效',
|
|
1226
|
+
deliveryInjectFailed: (detail) => `向活动会话注入失败:${detail}`,
|
|
814
1227
|
enableDone: '✅ 个性化设置已启用',
|
|
815
1228
|
disableDone: '✅ 个性化设置已禁用',
|
|
816
1229
|
nicknameDone: (n) => `✅ 昵称已设置为:${n}`,
|
|
@@ -867,7 +1280,9 @@ const COMMAND_MESSAGES = {
|
|
|
867
1280
|
onLabel: 'on',
|
|
868
1281
|
offLabel: 'off',
|
|
869
1282
|
personasLabel: 'Personas',
|
|
870
|
-
|
|
1283
|
+
// 提议是**内存态**(重启即失效)、也没有 TTL,所以必须让用户看到它是什么时候提出的,
|
|
1284
|
+
// 否则一条陈旧提议会一直挂着而无法判断新鲜度(proposedAt 此前只写不读)。
|
|
1285
|
+
pendingHint: (at) => `⚠ Pending persona proposal (raised at ${at}): /soul confirm to apply, /soul reject to discard`,
|
|
871
1286
|
noPending: 'No pending persona proposal',
|
|
872
1287
|
confirmDone: (detail) => `✅ Persona changes applied: ${detail}`,
|
|
873
1288
|
rejectDone: '✅ Pending persona proposal discarded',
|
|
@@ -877,20 +1292,32 @@ const COMMAND_MESSAGES = {
|
|
|
877
1292
|
builtinDeleteBlocked: (name) => `"${name}" is a built-in persona and cannot be deleted`,
|
|
878
1293
|
useDone: (name) => `✅ Persona "${name}" applied`,
|
|
879
1294
|
useUnchanged: (name) => `ℹ Persona "${name}" matches the current config`,
|
|
1295
|
+
// 预设里部分字段的取值非法(历史值或手改):合法字段照常应用,被跳过的必须说出来
|
|
1296
|
+
usePartial: (name, fields) => `⚠ Persona "${name}" applied partially; skipped as invalid: ${fields}`,
|
|
1297
|
+
useAllInvalid: (name, fields) => `✗ Persona "${name}" has no applicable field: ${fields}`,
|
|
880
1298
|
personaMissing: (name) => `Persona "${name}" does not exist`,
|
|
881
1299
|
delDone: (name) => `✅ Persona "${name}" deleted`,
|
|
882
1300
|
listTitle: '📋 Personas',
|
|
883
1301
|
listEmpty: 'No personas yet — save the current config with /soul save <name>',
|
|
1302
|
+
// 列表行报告预设实际覆盖的人格维度数量(取代 v0.7.1 起恒为空的「昵称」列)
|
|
1303
|
+
listCovers: (n) => `covers ${n} field${n === 1 ? '' : 's'}`,
|
|
884
1304
|
saveUsage: 'Usage: /soul save <name> (1-30 chars)',
|
|
885
1305
|
useUsage: 'Usage: /soul use <name>',
|
|
886
1306
|
delUsage: 'Usage: /soul del <name>',
|
|
887
1307
|
setUsage: 'Usage: /soul set key=value ... (fields: enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
888
1308
|
unknownField: 'Unknown field',
|
|
889
|
-
invalidBoolean: (key) => `${key} must be true or false`,
|
|
1309
|
+
invalidBoolean: (key, value) => `${key} must be true or false (got: ${JSON.stringify(value)})`,
|
|
890
1310
|
setNoChanges: 'No config changes to apply',
|
|
891
1311
|
setDone: (keys) => `✅ Updated: ${keys.join(', ')}`,
|
|
892
1312
|
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',
|
|
893
1313
|
resetDone: '✅ Configuration reset to defaults',
|
|
1314
|
+
resetRecovered: (file) => `✅ Configuration reset to defaults (the damaged file was backed up as ${file})`,
|
|
1315
|
+
deliveryLabel: 'Delivery: ',
|
|
1316
|
+
deliveryOk: (delivered, total) => total === 0
|
|
1317
|
+
? 'no active sessions (the config will apply in a new session)'
|
|
1318
|
+
: `injected into ${delivered}/${total} active sessions`,
|
|
1319
|
+
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',
|
|
1320
|
+
deliveryInjectFailed: (detail) => `injection into active sessions failed: ${detail}`,
|
|
894
1321
|
enableDone: '✅ Personalization enabled',
|
|
895
1322
|
disableDone: '✅ Personalization disabled',
|
|
896
1323
|
nicknameDone: (n) => `✅ Nickname set to: ${n}`,
|
|
@@ -908,6 +1335,17 @@ const COMMAND_KEYWORDS = new Set(['show', 'reset', 'enable', 'disable', 'save',
|
|
|
908
1335
|
// /soul set 中按布尔解析的键;其余键以原始字符串交给 sanitizeConfig 校验
|
|
909
1336
|
const COMMAND_BOOLEAN_KEYS = new Set(['enabled', 'trailEnabled'])
|
|
910
1337
|
|
|
1338
|
+
// 配置字段名 → 小写形式。子命令关键字本就忽略大小写(见 handler 里的 toLowerCase),
|
|
1339
|
+
// 键名此前却严格区分:`/soul set STYLE=humorous` 会报「未知配置项」,与用户预期不一致。
|
|
1340
|
+
// 这里按小写**映射回真实字段名**(不是把键 toLowerCase —— 那会把 headingLists 压成
|
|
1341
|
+
// headinglists,反而匹配不上白名单)。非配置字段仍按未知处理。
|
|
1342
|
+
const COMMAND_FIELD_BY_LOWER = new Map(Object.keys(DEFAULT_CONFIG).map((key) => [key.toLowerCase(), key]))
|
|
1343
|
+
|
|
1344
|
+
// 归一化 /soul set 的键名:命中真实字段则用其准确写法,否则原样交给白名单判未知
|
|
1345
|
+
function normalizeSetKey(key) {
|
|
1346
|
+
return COMMAND_FIELD_BY_LOWER.get(key.toLowerCase()) || key
|
|
1347
|
+
}
|
|
1348
|
+
|
|
911
1349
|
// 解析 /soul set 的键值参数:key=value 对;不含 = 的 token 追加到上一个值
|
|
912
1350
|
// (支持含空格的文本值,如:/soul set bio=写代码 多年 经验)
|
|
913
1351
|
function parseSetArgs(rest) {
|
|
@@ -963,26 +1401,37 @@ function registerCommands(ctx) {
|
|
|
963
1401
|
`${t.replyLengthLabel}=${t.replyLengthNames[config.replyLength] || config.replyLength}`
|
|
964
1402
|
].join(t.listSep)
|
|
965
1403
|
const instructions = config.customInstructions || t.notSet
|
|
966
|
-
|
|
967
|
-
|
|
1404
|
+
// 预设数量用**合并视图**(内置 + 用户),与 /soul list 和 Web UI 一致:
|
|
1405
|
+
// 此前只数 config.personas(磁盘上的用户预设),于是同一个「人设预设」
|
|
1406
|
+
// 在 show 里显示 2、在 list 里显示 9。
|
|
1407
|
+
const personaCount = Object.keys(mergePersonas(config.personas)).length
|
|
1408
|
+
const pendingLine = pendingPersonaProposal
|
|
1409
|
+
? `\n${t.pendingHint(new Date(pendingPersonaProposal.proposedAt).toLocaleString())}`
|
|
1410
|
+
: ''
|
|
968
1411
|
const trailText = t.trailLine(
|
|
969
1412
|
config.trailEnabled ? t.enabled : t.disabled,
|
|
970
1413
|
config.trailColor,
|
|
971
1414
|
t.trailSpeedNames[config.trailSpeed] || config.trailSpeed,
|
|
972
1415
|
t.trailWidthNames[config.trailWidth] || config.trailWidth
|
|
973
1416
|
)
|
|
1417
|
+
// 送达状态:直接回答「我刚改了配置,为什么没生效」这个高频疑问 ——
|
|
1418
|
+
// 两条通道里注入是否真的送到了活动会话,此前完全没有可观测点。
|
|
1419
|
+
const delivery = deliveryProblemText(config) || t.deliveryOk(deliveryState.delivered, deliveryState.agents)
|
|
974
1420
|
|
|
975
1421
|
return {
|
|
976
1422
|
kind: 'success',
|
|
977
|
-
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\n${t.help}`
|
|
1423
|
+
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}`
|
|
978
1424
|
}
|
|
979
1425
|
}
|
|
980
1426
|
|
|
981
1427
|
if (first === 'reset') {
|
|
982
|
-
//
|
|
983
|
-
|
|
1428
|
+
// 重置活动配置,但保留人设预设库。
|
|
1429
|
+
// 经由逃生口:配置损坏时这是唯一还能落盘的操作(先备份损坏文件)。
|
|
1430
|
+
const { config: updated, promptChanged, backupPath } = await resetConfigWithEscape()
|
|
984
1431
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
985
|
-
return
|
|
1432
|
+
return backupPath
|
|
1433
|
+
? { kind: 'success', text: t.resetRecovered(backupPath) }
|
|
1434
|
+
: { kind: 'success', text: t.resetDone }
|
|
986
1435
|
}
|
|
987
1436
|
|
|
988
1437
|
if (first === 'enable') {
|
|
@@ -1020,13 +1469,20 @@ function registerCommands(ctx) {
|
|
|
1020
1469
|
if (!entry) {
|
|
1021
1470
|
return { kind: 'error', text: t.personaMissing(personaName) }
|
|
1022
1471
|
}
|
|
1023
|
-
//
|
|
1024
|
-
|
|
1025
|
-
const {
|
|
1472
|
+
// 预设值保存时已校验;此处再过一遍白名单,防御手改文件等异常数据。
|
|
1473
|
+
// 非法字段不再被静默丢弃:applied=0 ⇒ 报错;有部分非法 ⇒ 明示被跳过的字段。
|
|
1474
|
+
const { changed, invalid, applied } = await applyPersonaEntry(ctx, entry)
|
|
1475
|
+
const fieldsText = invalid.join(t.listSep)
|
|
1476
|
+
// 预设未声明任何字段(applied=0 且 invalid=0)不是错误,是「无可应用内容」
|
|
1477
|
+
if (invalid.length > 0 && applied === 0) {
|
|
1478
|
+
return { kind: 'error', text: t.useAllInvalid(personaName, fieldsText) }
|
|
1479
|
+
}
|
|
1480
|
+
if (invalid.length > 0) {
|
|
1481
|
+
return { kind: 'success', text: t.usePartial(personaName, fieldsText) }
|
|
1482
|
+
}
|
|
1026
1483
|
if (changed.length === 0) {
|
|
1027
1484
|
return { kind: 'success', text: t.useUnchanged(personaName) }
|
|
1028
1485
|
}
|
|
1029
|
-
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1030
1486
|
return { kind: 'success', text: t.useDone(personaName) }
|
|
1031
1487
|
}
|
|
1032
1488
|
|
|
@@ -1041,9 +1497,13 @@ function registerCommands(ctx) {
|
|
|
1041
1497
|
const lines = names.map((personaName) => {
|
|
1042
1498
|
const entry = view[personaName]
|
|
1043
1499
|
const styleText = STYLE_VALUES.includes(entry.style) ? (t.styleNames[entry.style] || entry.style) : (entry.style || '-')
|
|
1044
|
-
|
|
1500
|
+
// 原先这里显示 `昵称=${entry.nickname || '-'}`,但 v0.7.1 起预设**不含**
|
|
1501
|
+
// 「关于你」(PERSONA_FIELDS 里没有 nickname,normalizePersonas 也会剥离)
|
|
1502
|
+
// ⇒ 该列恒为「昵称=-」,是一列永远没有信息的死数据。改为报告它实际
|
|
1503
|
+
// 覆盖了几个人格维度:这才是「应用这个预设会改变多少东西」的有效信息。
|
|
1504
|
+
const covers = declaredPersonaKeys(entry).length
|
|
1045
1505
|
const mark = entry.builtin ? ` [${t.builtinName}]` : ''
|
|
1046
|
-
return `${personaName === activeName ? '✔ ' : '• '}${personaName}${mark} | ${t.styleLabel}=${styleText} | ${t.
|
|
1506
|
+
return `${personaName === activeName ? '✔ ' : '• '}${personaName}${mark} | ${t.styleLabel}=${styleText} | ${t.listCovers(covers)}`
|
|
1047
1507
|
})
|
|
1048
1508
|
return { kind: 'success', text: `${t.listTitle} [${names.length}]\n${lines.join('\n')}` }
|
|
1049
1509
|
}
|
|
@@ -1069,12 +1529,17 @@ function registerCommands(ctx) {
|
|
|
1069
1529
|
const pairs = parseSetArgs(rest)
|
|
1070
1530
|
if (!pairs || pairs.length === 0) return { kind: 'error', text: t.setUsage }
|
|
1071
1531
|
const rawPatch = {}
|
|
1072
|
-
for (const [
|
|
1532
|
+
for (const [rawKey, rawValue] of pairs) {
|
|
1533
|
+
// 键名忽略大小写(与子命令关键字一致);值统一 trim(枚举此前不 trim)
|
|
1534
|
+
const key = normalizeSetKey(rawKey)
|
|
1535
|
+
const value = rawValue.trim()
|
|
1073
1536
|
if (COMMAND_BOOLEAN_KEYS.has(key)) {
|
|
1074
1537
|
const flag = value.toLowerCase()
|
|
1075
1538
|
if (['true', '1', 'on', 'yes'].includes(flag)) rawPatch[key] = true
|
|
1076
1539
|
else if (['false', '0', 'off', 'no'].includes(flag)) rawPatch[key] = false
|
|
1077
|
-
|
|
1540
|
+
// 带上收到的实际值:`enabled=true extra` 会解析成值 "true extra",
|
|
1541
|
+
// 只说「必须为 true / false」会让人以为是关键字写错了
|
|
1542
|
+
else return { kind: 'error', text: t.failed(t.invalidBoolean(key, value)) }
|
|
1078
1543
|
} else {
|
|
1079
1544
|
rawPatch[key] = value
|
|
1080
1545
|
}
|
|
@@ -1084,7 +1549,9 @@ function registerCommands(ctx) {
|
|
|
1084
1549
|
if (errorKeys.length > 0) {
|
|
1085
1550
|
return { kind: 'error', text: t.failed(`${errorKeys[0]}: ${errors[errorKeys[0]]}`) }
|
|
1086
1551
|
}
|
|
1087
|
-
|
|
1552
|
+
// hasOwn 而非 `in`:后者会命中原型链(`constructor` 之类恒为 true),
|
|
1553
|
+
// 让真正未知的字段被误判成「已识别」而不进 droppedKeys。
|
|
1554
|
+
const droppedKeys = Object.keys(rawPatch).filter((key) => !hasOwn(patch, key))
|
|
1088
1555
|
if (Object.keys(patch).length === 0) {
|
|
1089
1556
|
return { kind: 'error', text: t.failed(droppedKeys.length > 0 ? `${t.unknownField}: ${droppedKeys.join(', ')}` : t.setUsage) }
|
|
1090
1557
|
}
|
|
@@ -1129,21 +1596,33 @@ function registerSystemPrompt(ctx) {
|
|
|
1129
1596
|
let disposeSection = null
|
|
1130
1597
|
|
|
1131
1598
|
const registerSection = () => {
|
|
1132
|
-
// 重新注册前先移除旧 section
|
|
1133
|
-
//
|
|
1599
|
+
// 重新注册前先移除旧 section。
|
|
1600
|
+
//
|
|
1601
|
+
// 注意:这里**不是**靠 `system-prompt/change` 让宿主丢弃快照 —— 该事件当前全树
|
|
1602
|
+
// 没有任何消费者(只在 dsh-system-prompt 内部 emit)。真正让新配置生效的是
|
|
1603
|
+
// section 的 text provider 在每次装配时重新求值(宿主对函数式 text 不做缓存),
|
|
1604
|
+
// 以及活动会话注入这条第二条通道。两件事都有回归兜底:
|
|
1605
|
+
// scripts/verify-live-prompt.mjs / verify-e2e-prompt.mjs。
|
|
1606
|
+
//
|
|
1607
|
+
// 保留「先移除再注册」是因为宿主按名字插入、同名会抛重复错。
|
|
1134
1608
|
disposeSection?.()
|
|
1135
1609
|
|
|
1136
1610
|
disposeSection = spCtx.systemPrompt.section({
|
|
1137
1611
|
name: 'soul:persona',
|
|
1138
1612
|
order: 0,
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1613
|
+
// 关闭宿主的提示词插值(默认开启)。插值是严格模式:文本里每一对完整的
|
|
1614
|
+
// {{...}} 都必须命中一个已注册变量,否则 renderPrompt 抛错;而本 section
|
|
1615
|
+
// 的装配里变量集为空 ⇒ 用户在「自定义指令」里写一句模板语法(例如 {{name}})
|
|
1616
|
+
// 就会让整个 system prompt 装配失败。该抛错位于 agent.step() 开头且无
|
|
1617
|
+
// try/catch ⇒ 不是「忽略那段文字」,而是**该会话的每一轮都失败**。
|
|
1618
|
+
//
|
|
1619
|
+
// 代价为 0:我们不使用任何宿主提示词变量(需要上下文时,text 本身就是
|
|
1620
|
+
// provider)。关掉它同时让「预览所见」与「真实注入」逐字符一致。
|
|
1621
|
+
interpolate: false,
|
|
1622
|
+
text: () => compilePrompt(configCache || DEFAULT_CONFIG)
|
|
1146
1623
|
})
|
|
1624
|
+
|
|
1625
|
+
deliveryState.sectionRegisteredAt = new Date().toISOString()
|
|
1147
1626
|
}
|
|
1148
1627
|
|
|
1149
1628
|
registerSection()
|
|
@@ -1151,12 +1630,12 @@ function registerSystemPrompt(ctx) {
|
|
|
1151
1630
|
globalUpdatePrompt = async () => {
|
|
1152
1631
|
await loadConfig()
|
|
1153
1632
|
registerSection()
|
|
1154
|
-
// console.log('[dsh-soul] 系统提示词已更新,当前会话下一次请求将使用最新配置')
|
|
1155
1633
|
}
|
|
1156
1634
|
|
|
1157
1635
|
ctx.effect(() => () => {
|
|
1158
1636
|
disposeSection?.()
|
|
1159
1637
|
disposeSection = null
|
|
1638
|
+
deliveryState.sectionRegisteredAt = null
|
|
1160
1639
|
if (globalUpdatePrompt) {
|
|
1161
1640
|
globalUpdatePrompt = null
|
|
1162
1641
|
}
|
|
@@ -1164,6 +1643,117 @@ function registerSystemPrompt(ctx) {
|
|
|
1164
1643
|
})
|
|
1165
1644
|
}
|
|
1166
1645
|
|
|
1646
|
+
// ==================== 人设变更的宿主审批 ====================
|
|
1647
|
+
//
|
|
1648
|
+
// 确认模式开启时,人设变更走**宿主的原生审批交互**(输入框处的
|
|
1649
|
+
// 「允许一次 / 拒绝」面板),而不是让用户手敲 /soul confirm。
|
|
1650
|
+
//
|
|
1651
|
+
// 为什么复用宿主审批而不是自绘弹框:
|
|
1652
|
+
// · 面板、键盘(Enter 允许 / Esc 拒绝)、读屏、一次性授权语义都由宿主实现;
|
|
1653
|
+
// · ask / decided 会写入会话审计日志(谁在何时批准了什么人设变更);
|
|
1654
|
+
// · 多个插件同时要审批时由 waterfall 自然排队,不会出现两个弹框抢焦点。
|
|
1655
|
+
//
|
|
1656
|
+
// 返回三态,调用方据此决定继续落盘 / 拒绝 / 退化:
|
|
1657
|
+
// 'allowed' —— 用户批准,调用方继续写盘
|
|
1658
|
+
// 'rejected' —— 用户拒绝,调用方返回失败
|
|
1659
|
+
// 'unavailable'—— 拿不到审批能力(无 agent、宿主未装审批服务、无 Web UI 应答者),
|
|
1660
|
+
// 调用方退化到 /soul confirm 文本流程
|
|
1661
|
+
//
|
|
1662
|
+
// 刻意**不加超时**:用户不点就一直等,与宿主其它审批一致。
|
|
1663
|
+
// 加超时会产生「只是没来得及点就被自动拒绝」这种难以解释的行为。
|
|
1664
|
+
function approvalOutcomeOf(outcome) {
|
|
1665
|
+
if (outcome === 'allowed-once') return 'allowed'
|
|
1666
|
+
if (outcome === 'rejected') return 'rejected'
|
|
1667
|
+
// 'cancelled'(请求被撤回)与 'unavailable'(无应答者)都归为「审批不可用」,
|
|
1668
|
+
// 交由文本流程兜底 —— 用户仍有机会通过 /soul confirm 应用这次变更。
|
|
1669
|
+
return 'unavailable'
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// 读取当前生效的审批策略。复刻宿主 ApprovalService.effectivePolicy 的算法
|
|
1673
|
+
// (overrideOf(session) ?? config.policy ?? 'ask')。
|
|
1674
|
+
//
|
|
1675
|
+
// 任一项不可读时保守回落到 'ask' —— 回落到 ask 意味着「仍去问用户」,
|
|
1676
|
+
// 失败方向是「多问一次」而不是「静默改配置」。
|
|
1677
|
+
function resolveApprovalPolicy(approval, agent) {
|
|
1678
|
+
try {
|
|
1679
|
+
const session = agent && agent.session
|
|
1680
|
+
if (typeof approval.overrideOf === 'function' && session) {
|
|
1681
|
+
const override = approval.overrideOf(session)
|
|
1682
|
+
if (override === 'never' || override === 'ask') return override
|
|
1683
|
+
}
|
|
1684
|
+
const configured = approval.config && approval.config.policy
|
|
1685
|
+
if (configured === 'never' || configured === 'ask') return configured
|
|
1686
|
+
} catch {
|
|
1687
|
+
// 读策略失败不应影响主流程 —— 继续按 ask 走(让宿主的真实判定说话)
|
|
1688
|
+
}
|
|
1689
|
+
return 'ask'
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function requestToolApproval(ctx, exec, changes) {
|
|
1693
|
+
try {
|
|
1694
|
+
// agent 是必须的:宿主客户端表单靠它解析会话归属,拿不到就会放弃应答
|
|
1695
|
+
//(dsh-client-ui-approval 里 scopeOf(owner) === undefined 时直接 next())。
|
|
1696
|
+
const agent = exec && exec.agent
|
|
1697
|
+
if (!agent) return 'unavailable'
|
|
1698
|
+
// 服务解析:以**注册工具的 ctx**(toolsCtx)为主 —— 这与 dsh-tools 自己的
|
|
1699
|
+
// serviceAsk 写法一致(它在工具执行中用 this.ctx.get('approval'))。
|
|
1700
|
+
//
|
|
1701
|
+
// 实测教训:最初传的是 registerTools 的根 ctx,ctx.get('approval') 返回
|
|
1702
|
+
// undefined ⇒ 每次都退化成 /soul confirm 文本流程,看起来像「功能没生效」。
|
|
1703
|
+
// 另外 cordis 的 agent 作用域可能更精确(审批本就该路由到该 agent 的会话),
|
|
1704
|
+
// 故一并作为候选,任一命中即用。
|
|
1705
|
+
const candidates = []
|
|
1706
|
+
if (agent && typeof agent.ctx === 'object' && agent.ctx && typeof agent.ctx.get === 'function') candidates.push(agent.ctx)
|
|
1707
|
+
if (ctx && typeof ctx.get === 'function') candidates.push(ctx)
|
|
1708
|
+
let approval
|
|
1709
|
+
for (const c of candidates) {
|
|
1710
|
+
const found = c.get('approval')
|
|
1711
|
+
if (found && typeof found.request === 'function') { approval = found; break }
|
|
1712
|
+
}
|
|
1713
|
+
if (!approval || typeof approval.request !== 'function') return 'unavailable'
|
|
1714
|
+
|
|
1715
|
+
// —— 策略预判:避免把「通道被禁用」误报成「用户拒绝」——
|
|
1716
|
+
//
|
|
1717
|
+
// 宿主在 effectivePolicy(session) === 'never' 时**不问任何人**,直接返回
|
|
1718
|
+
// 'rejected'(见 dsh-user-approval 的 decide())。而 'rejected' 这个取值把
|
|
1719
|
+
// 两件完全不同的事混在一起:
|
|
1720
|
+
// ① 用户真的点了「拒绝」
|
|
1721
|
+
// ② 审批通道被策略禁用,无人被询问
|
|
1722
|
+
// 若不区分,就会向用户报告一件**从未发生的事**(实测踩过:策略为 never 时
|
|
1723
|
+
// 提示「你在审批面板中拒绝了」,而用户根本没看到面板)。
|
|
1724
|
+
//
|
|
1725
|
+
// 故在发起请求前复刻宿主的策略算法,判定为 'never' 时直接走文本确认
|
|
1726
|
+
// (与 'unavailable' 同一落点),并给出准确文案。
|
|
1727
|
+
//
|
|
1728
|
+
// 为什么不直接应用变更(把 never 当作「无需确认」):requireToolConfirmation
|
|
1729
|
+
// 是用户为本功能单独设的偏好,而 never 是会话级的审批通道策略。用后者悄悄推翻
|
|
1730
|
+
// 前者,等于让一个通道开关替用户取消了他自己的功能选择。真要免确认,正确做法是
|
|
1731
|
+
// 关掉设置页里的「人设变更需确认」开关 —— 那才是为这件事准备的开关。
|
|
1732
|
+
if (resolveApprovalPolicy(approval, agent) === 'never') return 'policy-disabled'
|
|
1733
|
+
|
|
1734
|
+
const detail = Object.entries(changes)
|
|
1735
|
+
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
|
1736
|
+
.join(', ')
|
|
1737
|
+
const outcome = await approval.request({
|
|
1738
|
+
agent,
|
|
1739
|
+
toolName: 'set_persona',
|
|
1740
|
+
...(exec && exec.callId !== undefined ? { callId: exec.callId } : {}),
|
|
1741
|
+
...(exec && exec.signal !== undefined ? { signal: exec.signal } : {}),
|
|
1742
|
+
reason: '人设变更需要你的确认',
|
|
1743
|
+
// 中英并列:宿主按当前 UI 语言选取,审计日志里仍保留 reason 原文
|
|
1744
|
+
displayReason: {
|
|
1745
|
+
en: 'Persona change requires your confirmation',
|
|
1746
|
+
zh: '人设变更需要你的确认',
|
|
1747
|
+
'zh-Hans': '人设变更需要你的确认'
|
|
1748
|
+
}
|
|
1749
|
+
})
|
|
1750
|
+
return approvalOutcomeOf(outcome)
|
|
1751
|
+
} catch {
|
|
1752
|
+
// 审批链路任何异常都不应让人设变更彻底失败 —— 退化为文本确认
|
|
1753
|
+
return 'unavailable'
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1167
1757
|
// ==================== Agent 可调用工具 ====================
|
|
1168
1758
|
|
|
1169
1759
|
// tools 是可选 host 服务且 ABI 版本敏感(同 dsh-share-page 的守卫)。
|
|
@@ -1177,53 +1767,62 @@ function registerTools(ctx) {
|
|
|
1177
1767
|
}
|
|
1178
1768
|
toolsCtx.tools.register(defineTool({
|
|
1179
1769
|
name: 'set_persona',
|
|
1770
|
+
// 描述是**模型侧**文本,中英并列:工具 schema 只在 apply 时注册一次,而
|
|
1771
|
+
// `tools.register` 对同名重注册会抛 "already registered",语言切换时无法
|
|
1772
|
+
// 原地换描述(registry 只在注销旧句柄后才接受同名)。并列两种语言既覆盖
|
|
1773
|
+
// zh / en 两种界面,又不必引入重注册的时序风险。
|
|
1180
1774
|
description:
|
|
1181
|
-
'
|
|
1775
|
+
'调整当前个性化设置(昵称、回复风格和语调、自定义指令)。' +
|
|
1182
1776
|
'当用户明确要求改变称呼、语气、风格或角色时使用。' +
|
|
1183
|
-
'
|
|
1777
|
+
'只需要传入要修改的字段,未提供的字段保持不变。\n' +
|
|
1778
|
+
'Adjust the current personalization settings (nickname, reply style & tone, custom instructions). ' +
|
|
1779
|
+
'Use it when the user explicitly asks to change how they are addressed, the tone, the style, or the role. ' +
|
|
1780
|
+
'Pass only the fields to change; omitted fields keep their current values.',
|
|
1184
1781
|
parameters: {
|
|
1185
|
-
nickname: { type: 'string', description: '
|
|
1186
|
-
occupation: { type: 'string', description: '
|
|
1187
|
-
bio: { type: 'string', description: '
|
|
1782
|
+
nickname: { type: 'string', description: '用户昵称,回复时会使用这个称呼。/ The nickname to address the user by.' },
|
|
1783
|
+
occupation: { type: 'string', description: '用户的职业。/ The user’s occupation.' },
|
|
1784
|
+
bio: { type: 'string', description: '关于用户的介绍。/ Background about the user.' },
|
|
1188
1785
|
style: {
|
|
1189
1786
|
type: 'string',
|
|
1190
1787
|
enum: STYLE_VALUES,
|
|
1191
|
-
description: '回复风格和语调:professional=专业严谨,casual=轻松自然,humorous=幽默风趣,roast=吐槽达人,efficient
|
|
1788
|
+
description: '回复风格和语调:professional=专业严谨,casual=轻松自然,humorous=幽默风趣,roast=吐槽达人,efficient=高效干练。/ Reply style & tone.'
|
|
1192
1789
|
},
|
|
1193
1790
|
language: {
|
|
1194
1791
|
type: 'string',
|
|
1195
1792
|
enum: LANGUAGE_VALUES,
|
|
1196
|
-
description: '回复语言:zh=简体中文,en=English
|
|
1793
|
+
description: '回复语言:zh=简体中文,en=English。/ Reply language.'
|
|
1197
1794
|
},
|
|
1198
1795
|
headingLists: {
|
|
1199
1796
|
type: 'string',
|
|
1200
1797
|
enum: TRAIT_VALUES,
|
|
1201
|
-
description: '特质·标题和列表:default=默认,more=增强(采用清晰格式和列表结构),less
|
|
1798
|
+
description: '特质·标题和列表:default=默认,more=增强(采用清晰格式和列表结构),less=减弱(使用更多段落文本)。/ Headings & lists.'
|
|
1202
1799
|
},
|
|
1203
1800
|
emoji: {
|
|
1204
1801
|
type: 'string',
|
|
1205
1802
|
enum: TRAIT_VALUES,
|
|
1206
|
-
description: '特质·表情符号:default=默认,more=增强(使用较多表情符号),less
|
|
1803
|
+
description: '特质·表情符号:default=默认,more=增强(使用较多表情符号),less=减弱(尽量减少使用表情符号)。/ Emoji usage.'
|
|
1207
1804
|
},
|
|
1208
1805
|
tables: {
|
|
1209
1806
|
type: 'string',
|
|
1210
1807
|
enum: TRAIT_VALUES,
|
|
1211
|
-
description: '特质·表格:default=默认,more=增强(呈现对比、多字段或结构化信息时优先使用表格),less
|
|
1808
|
+
description: '特质·表格:default=默认,more=增强(呈现对比、多字段或结构化信息时优先使用表格),less=减弱(避免表格,改用列表或段落文本)。/ Table usage.'
|
|
1212
1809
|
},
|
|
1213
1810
|
replyLength: {
|
|
1214
1811
|
type: 'string',
|
|
1215
1812
|
enum: REPLY_LENGTH_VALUES,
|
|
1216
|
-
description: '回复长度偏好:concise=简洁(直击要点、不展开),normal=适中(不额外约束),detailed
|
|
1813
|
+
description: '回复长度偏好:concise=简洁(直击要点、不展开),normal=适中(不额外约束),detailed=详尽(充分展开背景、步骤与推理)。/ Reply length preference.'
|
|
1217
1814
|
},
|
|
1218
|
-
customInstructions: { type: 'string', description: '
|
|
1815
|
+
customInstructions: { type: 'string', description: '额外的自定义指令,用于覆盖或补充当前人设。/ Extra custom instructions that override or extend the current persona.' },
|
|
1219
1816
|
},
|
|
1220
1817
|
output: {
|
|
1221
1818
|
schema: {
|
|
1222
1819
|
type: 'object',
|
|
1223
1820
|
additionalProperties: false,
|
|
1224
|
-
required
|
|
1821
|
+
// 注意:DSH 的 schema DSL **不支持根级 required**(作者错误:
|
|
1822
|
+
// "schema.required is not supported by the value schema DSL")。
|
|
1823
|
+
// 必填只能标在属性上(property.required must be true when present)。
|
|
1225
1824
|
properties: {
|
|
1226
|
-
ok: { type: 'boolean' },
|
|
1825
|
+
ok: { type: 'boolean', required: true },
|
|
1227
1826
|
message: { type: 'string' },
|
|
1228
1827
|
pending: {
|
|
1229
1828
|
type: 'boolean',
|
|
@@ -1232,6 +1831,9 @@ function registerTools(ctx) {
|
|
|
1232
1831
|
changes: {
|
|
1233
1832
|
type: 'object',
|
|
1234
1833
|
description: '实际发生变化的字段',
|
|
1834
|
+
// object 节点必须显式声明 additionalProperties
|
|
1835
|
+
// (作者错误:"...additionalProperties must be explicitly true or false")
|
|
1836
|
+
additionalProperties: false,
|
|
1235
1837
|
properties: {
|
|
1236
1838
|
nickname: { type: 'string' },
|
|
1237
1839
|
occupation: { type: 'string' },
|
|
@@ -1247,8 +1849,23 @@ function registerTools(ctx) {
|
|
|
1247
1849
|
},
|
|
1248
1850
|
},
|
|
1249
1851
|
},
|
|
1852
|
+
// 结果渲染:宿主每次工具**成功执行**后都会无条件调用它
|
|
1853
|
+
// (dsh-tools 的 createSuccessResult → tool.output.render(...))。
|
|
1854
|
+
// 不提供会怎样:defineTool 无条件下包一份 `render(args, value) { return userRender(...) }`,
|
|
1855
|
+
// 而 userRender 是 undefined ⇒ 注册能过(底层 register 校验的是包装后的函数),
|
|
1856
|
+
// 但**每次成功调用都在渲染阶段抛 TypeError: userRender is not a function**,
|
|
1857
|
+
// 宿主把它包成 projectionError("render"),用户视角是「Agent 说改了却报错」。
|
|
1858
|
+
// 自带插件 19 个都提供它。契约:返回 [{ type: 'text', text }](无损 JSON)。
|
|
1859
|
+
render: (_args, value) => {
|
|
1860
|
+
// 直接把 message 作为给模型读的文本:它已经是完整的一句话(含 pending 提议与
|
|
1861
|
+
// 失败原因),再套一层 JSON.stringify 只会让模型多解析一层转义。
|
|
1862
|
+
const text = typeof value?.message === 'string' && value.message.length > 0
|
|
1863
|
+
? value.message
|
|
1864
|
+
: (value?.ok === true ? 'ok' : 'failed')
|
|
1865
|
+
return [{ type: 'text', text }]
|
|
1866
|
+
},
|
|
1250
1867
|
},
|
|
1251
|
-
async execute(args) {
|
|
1868
|
+
async execute(args, exec) {
|
|
1252
1869
|
try {
|
|
1253
1870
|
// 与 HTTP 保存共用同一套校验(白名单 / 类型 / 长度 / 枚举)
|
|
1254
1871
|
const { patch, errors } = sanitizeConfig(args)
|
|
@@ -1261,7 +1878,22 @@ function registerTools(ctx) {
|
|
|
1261
1878
|
return { ok: true, message: T0.toolNoChanges }
|
|
1262
1879
|
}
|
|
1263
1880
|
|
|
1264
|
-
//
|
|
1881
|
+
// 确认模式:先走**宿主的原生审批交互**(输入框处的「允许一次 / 拒绝」面板)。
|
|
1882
|
+
//
|
|
1883
|
+
// 三态处理:
|
|
1884
|
+
// allowed → 用户已批准,继续往下走写盘(故这里不 return)
|
|
1885
|
+
// rejected → 用户拒绝,返回失败且不落盘
|
|
1886
|
+
// 三态 + 一个策略态:
|
|
1887
|
+
// allowed → 用户已批准,继续往下走写盘(故这里不 return)
|
|
1888
|
+
// rejected → 用户明确拒绝,返回失败且不落盘
|
|
1889
|
+
// unavailable → 拿不到审批能力(无 agent / 宿主未装审批服务 /
|
|
1890
|
+
// 客户端没有应答者),退化到 /soul confirm 文本流程
|
|
1891
|
+
// policy-disabled → 审批通道被会话策略禁用(never)⇒ 同样走文本流程,
|
|
1892
|
+
// 但文案必须说明「通道被禁用」,不能谎称用户拒绝
|
|
1893
|
+
//
|
|
1894
|
+
// 退化路径必须保留:纯 TUI 或无 Web UI 的场景没有应答者,审批会返回
|
|
1895
|
+
// 'unavailable',此时文本流程是唯一可用的确认途径。删掉它会让这些场景
|
|
1896
|
+
// 彻底无法确认人设变更。
|
|
1265
1897
|
const snapshot = await loadConfig()
|
|
1266
1898
|
if (snapshot.requireToolConfirmation) {
|
|
1267
1899
|
const T0 = promptTextOf(snapshot)
|
|
@@ -1270,14 +1902,24 @@ function registerTools(ctx) {
|
|
|
1270
1902
|
return { ok: true, message: T0.toolNoChanges }
|
|
1271
1903
|
}
|
|
1272
1904
|
const proposalChanges = Object.fromEntries(pendingKeys.map((key) => [key, patch[key]]))
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1905
|
+
const verdict = await requestToolApproval(toolsCtx, exec, proposalChanges)
|
|
1906
|
+
if (verdict === 'rejected') {
|
|
1907
|
+
return { ok: false, message: T0.toolRejected }
|
|
1908
|
+
}
|
|
1909
|
+
if (verdict === 'unavailable' || verdict === 'policy-disabled') {
|
|
1910
|
+
pendingPersonaProposal = { patch: proposalChanges, proposedAt: new Date().toISOString() }
|
|
1911
|
+
const detail = pendingKeys.map((key) => `${key}=${JSON.stringify(patch[key])}`).join(', ')
|
|
1912
|
+
// 通道被禁用时补一句准确说明:否则用户会以为是面板出了问题,
|
|
1913
|
+
// 或误以为是自己拒绝了(此前的错误措辞正是这么来的)。
|
|
1914
|
+
const why = verdict === 'policy-disabled' ? `${T0.toolApprovalDisabled}\n` : ''
|
|
1915
|
+
return {
|
|
1916
|
+
ok: true,
|
|
1917
|
+
pending: true,
|
|
1918
|
+
changes: proposalChanges,
|
|
1919
|
+
message: `${T0.toolProposal}\n${detail}\n${why}${T0.toolProposalHint}`
|
|
1920
|
+
}
|
|
1280
1921
|
}
|
|
1922
|
+
// verdict === 'allowed' ⇒ 落到下面的写盘路径
|
|
1281
1923
|
}
|
|
1282
1924
|
|
|
1283
1925
|
// 写队列内合并,避免与其他写入方(Web UI、/soul 命令)竞态;
|
|
@@ -1328,7 +1970,10 @@ export async function apply(ctx) {
|
|
|
1328
1970
|
if (Object.keys(errors).length > 0) {
|
|
1329
1971
|
throw new Error('配置校验失败:' + formatFieldErrors(errors))
|
|
1330
1972
|
}
|
|
1331
|
-
const { config } = await commitConfig(current => ({ ...current, ...patch }))
|
|
1973
|
+
const { config, promptChanged } = await commitConfig(current => ({ ...current, ...patch }))
|
|
1974
|
+
// 送达与另外三条写路径保持一致:服务调用方(其它插件)改配置后同样应当
|
|
1975
|
+
// 立即反映到活动会话,否则「改了配置不生效」会从服务这条路再次出现。
|
|
1976
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
|
|
1332
1977
|
return config
|
|
1333
1978
|
},
|
|
1334
1979
|
async getSystemPrompt() {
|
|
@@ -1336,7 +1981,9 @@ export async function apply(ctx) {
|
|
|
1336
1981
|
return compilePrompt(config)
|
|
1337
1982
|
},
|
|
1338
1983
|
async resetConfig() {
|
|
1339
|
-
|
|
1984
|
+
// 与 HTTP / 斜杠命令两条重置入口共用逃生口
|
|
1985
|
+
const { config, promptChanged } = await resetConfigWithEscape()
|
|
1986
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
|
|
1340
1987
|
return config
|
|
1341
1988
|
}
|
|
1342
1989
|
})
|