dsh-soul 0.3.2 → 0.3.3
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/README.md +3 -0
- package/README_EN.md +3 -0
- package/client/index.mjs +5 -3
- package/index.mjs +123 -100
- package/lib/config.mjs +138 -0
- package/package.json +7 -2
- package/scripts/verify-config.mjs +142 -0
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ DeepSeek Harness 个性化设置插件,用于配置 Agent 的昵称、回复
|
|
|
17
17
|
- 输入自定义指令
|
|
18
18
|
- Agent 可调用工具 `set_persona`,让模型在对话中直接调整人设
|
|
19
19
|
- 配置持久化保存
|
|
20
|
+
- 配置输入校验:字段白名单、类型、长度上限(昵称/职业 50、介绍 500、自定义指令 2000 字符)与枚举校验,非法或超限字段整单拒绝
|
|
20
21
|
- 配置更新后同步到所有活动 Agent
|
|
21
22
|
|
|
22
23
|
## 安装
|
|
@@ -92,6 +93,8 @@ soul-config.json
|
|
|
92
93
|
}
|
|
93
94
|
```
|
|
94
95
|
|
|
96
|
+
字段长度上限:昵称 / 职业 50 字符,介绍 500 字符,自定义指令 2000 字符;未知字段会被丢弃,非法或超限字段整单拒绝(HTTP 返回 400 与字段级错误明细)。
|
|
97
|
+
|
|
95
98
|
## 实现原理
|
|
96
99
|
|
|
97
100
|
插件通过 `compilePrompt()` 将昵称、风格、语调和自定义指令编译成 system prompt,并注册到 DSH:
|
package/README_EN.md
CHANGED
|
@@ -17,6 +17,7 @@ A personalization plugin for DeepSeek Harness (DSH). Configure your agent's nick
|
|
|
17
17
|
- Custom instructions
|
|
18
18
|
- Agent-callable tool `set_persona` to let the model adjust persona during a conversation
|
|
19
19
|
- Configuration persisted to disk
|
|
20
|
+
- Input validation: field whitelist, types, length limits (nickname/occupation 50, bio 500, custom instructions 2000 chars) and enum checks; invalid or oversized fields reject the whole write
|
|
20
21
|
- Configuration synced to all active agents after every update
|
|
21
22
|
|
|
22
23
|
## Installation
|
|
@@ -92,6 +93,8 @@ Example:
|
|
|
92
93
|
}
|
|
93
94
|
```
|
|
94
95
|
|
|
96
|
+
Length limits: nickname / occupation 50 chars, bio 500 chars, custom instructions 2000 chars. Unknown fields are dropped; invalid or oversized fields reject the whole write (HTTP returns 400 with per-field error details).
|
|
97
|
+
|
|
95
98
|
## How It Works
|
|
96
99
|
|
|
97
100
|
The plugin compiles the nickname, style, tone and custom instructions into a system prompt via `compilePrompt()` and registers it with DSH:
|
package/client/index.mjs
CHANGED
|
@@ -616,13 +616,15 @@ window.__ModuleLoader__.load({
|
|
|
616
616
|
}
|
|
617
617
|
|
|
618
618
|
// 延迟执行初始同步,等待设置页面渲染完成
|
|
619
|
-
|
|
619
|
+
let timer = setTimeout(sync, 500)
|
|
620
620
|
|
|
621
621
|
// 监听 DOM 变化(只监听子节点添加,不监听所有变化)
|
|
622
622
|
const observer = new MutationObserver(() => {
|
|
623
|
-
//
|
|
623
|
+
// 防抖:每次变化都重置计时器,停止变化 100ms 后执行
|
|
624
|
+
// (修复:此前新计时器未赋回 timer,clearTimeout 永远只清除首个 500ms
|
|
625
|
+
// 计时器,防抖实际失效,每次 DOM 变化都会调度一次 sync)
|
|
624
626
|
clearTimeout(timer)
|
|
625
|
-
setTimeout(sync, 100)
|
|
627
|
+
timer = setTimeout(sync, 100)
|
|
626
628
|
})
|
|
627
629
|
|
|
628
630
|
observer.observe(document.body, {
|
package/index.mjs
CHANGED
|
@@ -17,6 +17,14 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { homedir } from 'node:os'
|
|
19
19
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_CONFIG,
|
|
22
|
+
STYLE_VALUES,
|
|
23
|
+
TRAIT_VALUES,
|
|
24
|
+
LANGUAGE_VALUES,
|
|
25
|
+
migrateConfig,
|
|
26
|
+
sanitizeConfig
|
|
27
|
+
} from './lib/config.mjs'
|
|
20
28
|
|
|
21
29
|
const name = 'soul'
|
|
22
30
|
|
|
@@ -155,59 +163,11 @@ const StyleLayer = {
|
|
|
155
163
|
|
|
156
164
|
// ==================== 配置管理 ====================
|
|
157
165
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
enabled: true,
|
|
161
|
-
nickname: '',
|
|
162
|
-
// 关于你:用户职业 / 用户介绍
|
|
163
|
-
occupation: '',
|
|
164
|
-
bio: '',
|
|
165
|
-
// 回复风格和语调(v0.2.0 起合并为单一选项)
|
|
166
|
-
style: 'professional',
|
|
167
|
-
// 特质:标题和列表 / 表情符号(default=默认,more=增强,less=减弱)
|
|
168
|
-
headingLists: 'default',
|
|
169
|
-
emoji: 'default',
|
|
170
|
-
language: 'zh',
|
|
171
|
-
customInstructions: ''
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 合法的回复风格和语调取值(供工具参数校验)
|
|
175
|
-
const STYLE_VALUES = ['professional', 'casual', 'humorous', 'roast', 'efficient']
|
|
176
|
-
|
|
177
|
-
// 特质合法取值
|
|
178
|
-
const TRAIT_VALUES = ['default', 'more', 'less']
|
|
179
|
-
|
|
180
|
-
// v0.1.x 配置使用 style + tone 两个字段;v0.2.0 起合并为单一 style(回复风格和语调)。
|
|
181
|
-
// 迁移映射:组合命中用组合表,否则退回旧 style 表,再否则用默认值。
|
|
182
|
-
const LEGACY_STYLE_TONE_MAP = {
|
|
183
|
-
'professional+formal': 'professional',
|
|
184
|
-
'casual+neutral': 'casual',
|
|
185
|
-
'humorous+informal': 'humorous'
|
|
186
|
-
}
|
|
187
|
-
const LEGACY_STYLE_MAP = {
|
|
188
|
-
professional: 'professional',
|
|
189
|
-
casual: 'casual',
|
|
190
|
-
friendly: 'casual',
|
|
191
|
-
humorous: 'humorous',
|
|
192
|
-
academic: 'professional'
|
|
193
|
-
}
|
|
166
|
+
// 默认配置、合法取值(STYLE_VALUES / TRAIT_VALUES / LANGUAGE_VALUES)、
|
|
167
|
+
// 旧版本迁移(migrateConfig)与输入校验(sanitizeConfig)见 lib/config.mjs。
|
|
194
168
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (raw && typeof raw.style === 'string' && typeof raw.tone === 'string') {
|
|
198
|
-
const combo = `${raw.style}+${raw.tone}`
|
|
199
|
-
config.style = LEGACY_STYLE_TONE_MAP[combo] || LEGACY_STYLE_MAP[raw.style] || DEFAULT_CONFIG.style
|
|
200
|
-
delete config.tone
|
|
201
|
-
}
|
|
202
|
-
// 人设预设 / 示例指令功能已在 v0.2.0 移除,丢弃历史残留字段
|
|
203
|
-
delete config.presets
|
|
204
|
-
delete config.tone
|
|
205
|
-
delete config.examples
|
|
206
|
-
// 特质字段校验:脏数据回退为默认值
|
|
207
|
-
if (!TRAIT_VALUES.includes(config.headingLists)) config.headingLists = 'default'
|
|
208
|
-
if (!TRAIT_VALUES.includes(config.emoji)) config.emoji = 'default'
|
|
209
|
-
return config
|
|
210
|
-
}
|
|
169
|
+
// POST /api/soul/config 请求体大小上限:合法配置远小于该值,仅用于拦截异常请求
|
|
170
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
211
171
|
|
|
212
172
|
// 内存中的配置缓存
|
|
213
173
|
let configCache = null
|
|
@@ -239,6 +199,36 @@ async function saveConfig(config) {
|
|
|
239
199
|
return clean
|
|
240
200
|
}
|
|
241
201
|
|
|
202
|
+
// ==================== 配置写入队列 ====================
|
|
203
|
+
|
|
204
|
+
// 所有配置写路径(HTTP 保存 / /soul 命令 / set_persona 工具 / soulConfig 服务)
|
|
205
|
+
// 统一经由该队列串行执行「读—改—写」,避免并发写入时各方都基于同一份旧配置合并、
|
|
206
|
+
// 后写覆盖前写(丢更新)。队列按提交顺序执行任务。
|
|
207
|
+
let configWriteQueue = Promise.resolve()
|
|
208
|
+
|
|
209
|
+
function enqueueConfigWrite(task) {
|
|
210
|
+
const run = configWriteQueue.then(task)
|
|
211
|
+
// 队列吞掉单次任务的失败,保证后续写入不会被一次磁盘错误卡死
|
|
212
|
+
configWriteQueue = run.then(() => {}, () => {})
|
|
213
|
+
return run
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 在写队列中执行一次配置更新:mutate 收到当前配置的浅拷贝,返回新配置对象。
|
|
217
|
+
// 注意 mutate 必须在队列任务内完成全部读取与合并,不要在队列外提前读取配置。
|
|
218
|
+
async function commitConfig(mutate) {
|
|
219
|
+
return enqueueConfigWrite(async () => {
|
|
220
|
+
const next = mutate({ ...(await loadConfig()) })
|
|
221
|
+
return await saveConfig(next)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 将字段级校验错误汇总为一行文本(HTTP 错误信息 / 工具返回消息共用)
|
|
226
|
+
function formatFieldErrors(errors) {
|
|
227
|
+
return Object.entries(errors)
|
|
228
|
+
.map(([field, reason]) => `${field}: ${reason}`)
|
|
229
|
+
.join(';')
|
|
230
|
+
}
|
|
231
|
+
|
|
242
232
|
// ==================== 提示词编译器 ====================
|
|
243
233
|
|
|
244
234
|
// Prompt Compiler:编译三层架构为系统提示词
|
|
@@ -361,11 +351,43 @@ function registerRoutes(ctx) {
|
|
|
361
351
|
const config = await loadConfig()
|
|
362
352
|
send(200, { ok: true, config })
|
|
363
353
|
} else if (req.method === 'POST') {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
354
|
+
// 读取请求体:超过大小上限则丢弃内容但继续读完剩余数据,
|
|
355
|
+
// 避免连接中残留未读字节污染 keep-alive
|
|
356
|
+
const chunks = []
|
|
357
|
+
let size = 0
|
|
358
|
+
let oversized = false
|
|
359
|
+
for await (const chunk of req) {
|
|
360
|
+
size += chunk.length
|
|
361
|
+
if (size > MAX_BODY_BYTES) {
|
|
362
|
+
oversized = true
|
|
363
|
+
chunks.length = 0
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
chunks.push(chunk)
|
|
367
|
+
}
|
|
368
|
+
if (oversized) {
|
|
369
|
+
send(413, { ok: false, error: `请求体超过大小上限 ${MAX_BODY_BYTES} 字节` })
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// JSON 解析失败属于客户端错误:返回 400 而非 500
|
|
374
|
+
let body
|
|
375
|
+
try {
|
|
376
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
377
|
+
} catch (err) {
|
|
378
|
+
send(400, { ok: false, error: '请求体不是合法的 JSON:' + String(err.message || err) })
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 输入校验:字段白名单 + 类型 + 长度上限 + 枚举;存在错误时整单拒绝
|
|
383
|
+
const { patch, errors } = sanitizeConfig(body)
|
|
384
|
+
if (Object.keys(errors).length > 0) {
|
|
385
|
+
send(400, { ok: false, error: '配置校验失败:' + formatFieldErrors(errors), errors })
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 写队列内「读—改—写」,只合并通过校验的字段(未知字段不落盘)
|
|
390
|
+
const updated = await commitConfig(current => ({ ...current, ...patch }))
|
|
369
391
|
|
|
370
392
|
// 配置变化后更新系统提示词,并注入所有活动会话
|
|
371
393
|
refreshPromptAndInject(ctx, updated)
|
|
@@ -413,7 +435,7 @@ function registerRoutes(ctx) {
|
|
|
413
435
|
}
|
|
414
436
|
|
|
415
437
|
try {
|
|
416
|
-
const config = await
|
|
438
|
+
const config = await commitConfig(() => ({ ...DEFAULT_CONFIG }))
|
|
417
439
|
|
|
418
440
|
// 配置变化后更新系统提示词,并注入所有活动会话
|
|
419
441
|
refreshPromptAndInject(ctx, config)
|
|
@@ -538,29 +560,31 @@ function registerCommands(ctx) {
|
|
|
538
560
|
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}${t.headingListsLabel}=${hlName},${t.emojiLabel}=${emojiName}\n${t.instructionsLabel}${t.colon}${instructions}\n\n${t.help}`
|
|
539
561
|
}
|
|
540
562
|
} else if (args === 'reset') {
|
|
541
|
-
const updated = await
|
|
563
|
+
const updated = await commitConfig(() => ({ ...DEFAULT_CONFIG }))
|
|
542
564
|
// 更新系统提示词,并注入所有活动会话
|
|
543
565
|
refreshPromptAndInject(ctx, updated)
|
|
544
566
|
return { kind: 'success', text: t.resetDone }
|
|
545
567
|
} else if (args === 'enable') {
|
|
546
|
-
|
|
547
|
-
const updated = await
|
|
568
|
+
// 写队列内「读—改—写」,不在队列外改写内存缓存对象
|
|
569
|
+
const updated = await commitConfig(c => ({ ...c, enabled: true }))
|
|
548
570
|
// 更新系统提示词,并注入所有活动会话
|
|
549
571
|
refreshPromptAndInject(ctx, updated)
|
|
550
572
|
return { kind: 'success', text: t.enableDone }
|
|
551
573
|
} else if (args === 'disable') {
|
|
552
|
-
|
|
553
|
-
const updated = await saveConfig(config)
|
|
574
|
+
const updated = await commitConfig(c => ({ ...c, enabled: false }))
|
|
554
575
|
// 更新系统提示词,并注入所有活动会话
|
|
555
576
|
refreshPromptAndInject(ctx, updated)
|
|
556
577
|
return { kind: 'success', text: t.disableDone }
|
|
557
578
|
} else {
|
|
558
|
-
// 设置昵称(保留原始大小写,不做 toLowerCase
|
|
559
|
-
|
|
560
|
-
|
|
579
|
+
// 设置昵称(保留原始大小写,不做 toLowerCase);与 HTTP 保存共用同一套校验
|
|
580
|
+
const { patch, errors } = sanitizeConfig({ nickname: raw })
|
|
581
|
+
if (errors.nickname) {
|
|
582
|
+
return { kind: 'error', text: t.failed(errors.nickname) }
|
|
583
|
+
}
|
|
584
|
+
const updated = await commitConfig(c => ({ ...c, nickname: patch.nickname }))
|
|
561
585
|
// 更新系统提示词,并注入所有活动会话
|
|
562
586
|
refreshPromptAndInject(ctx, updated)
|
|
563
|
-
return { kind: 'success', text: t.nicknameDone(
|
|
587
|
+
return { kind: 'success', text: t.nicknameDone(patch.nickname) }
|
|
564
588
|
}
|
|
565
589
|
} catch (err) {
|
|
566
590
|
return { kind: 'error', text: commandMessages(await loadConfig()).failed(err.message) }
|
|
@@ -643,7 +667,7 @@ function registerTools(ctx) {
|
|
|
643
667
|
},
|
|
644
668
|
language: {
|
|
645
669
|
type: 'string',
|
|
646
|
-
enum:
|
|
670
|
+
enum: LANGUAGE_VALUES,
|
|
647
671
|
description: '回复语言:zh=简体中文,en=English。'
|
|
648
672
|
},
|
|
649
673
|
headingLists: {
|
|
@@ -685,39 +709,35 @@ function registerTools(ctx) {
|
|
|
685
709
|
},
|
|
686
710
|
async execute(args) {
|
|
687
711
|
try {
|
|
688
|
-
|
|
689
|
-
const patch =
|
|
690
|
-
if (
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
if (typeof args.occupation === 'string' && args.occupation !== current.occupation) {
|
|
694
|
-
patch.occupation = args.occupation
|
|
695
|
-
}
|
|
696
|
-
if (typeof args.bio === 'string' && args.bio !== current.bio) {
|
|
697
|
-
patch.bio = args.bio
|
|
698
|
-
}
|
|
699
|
-
if (typeof args.style === 'string' && STYLE_VALUES.includes(args.style) && args.style !== current.style) {
|
|
700
|
-
patch.style = args.style
|
|
701
|
-
}
|
|
702
|
-
if (typeof args.language === 'string' && ['zh', 'en'].includes(args.language) && args.language !== current.language) {
|
|
703
|
-
patch.language = args.language
|
|
704
|
-
}
|
|
705
|
-
if (typeof args.headingLists === 'string' && TRAIT_VALUES.includes(args.headingLists) && args.headingLists !== current.headingLists) {
|
|
706
|
-
patch.headingLists = args.headingLists
|
|
707
|
-
}
|
|
708
|
-
if (typeof args.emoji === 'string' && TRAIT_VALUES.includes(args.emoji) && args.emoji !== current.emoji) {
|
|
709
|
-
patch.emoji = args.emoji
|
|
710
|
-
}
|
|
711
|
-
if (typeof args.customInstructions === 'string' && args.customInstructions !== current.customInstructions) {
|
|
712
|
-
patch.customInstructions = args.customInstructions
|
|
712
|
+
// 与 HTTP 保存共用同一套校验(白名单 / 类型 / 长度 / 枚举)
|
|
713
|
+
const { patch, errors } = sanitizeConfig(args)
|
|
714
|
+
if (Object.keys(errors).length > 0) {
|
|
715
|
+
return { ok: false, message: '配置校验失败:' + formatFieldErrors(errors) }
|
|
713
716
|
}
|
|
714
717
|
if (Object.keys(patch).length === 0) {
|
|
715
718
|
return { ok: true, message: '没有要更改的设置' }
|
|
716
719
|
}
|
|
717
|
-
|
|
718
|
-
|
|
720
|
+
|
|
721
|
+
// 在写队列内做 diff 与合并,避免与其他写入方(Web UI、/soul 命令)竞态
|
|
722
|
+
let changes = null
|
|
723
|
+
const updated = await commitConfig((current) => {
|
|
724
|
+
const next = { ...current }
|
|
725
|
+
changes = {}
|
|
726
|
+
for (const key of Object.keys(patch)) {
|
|
727
|
+
if (current[key] !== patch[key]) {
|
|
728
|
+
next[key] = patch[key]
|
|
729
|
+
changes[key] = patch[key]
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return next
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
if (!changes || Object.keys(changes).length === 0) {
|
|
736
|
+
return { ok: true, message: '没有要更改的设置' }
|
|
737
|
+
}
|
|
738
|
+
|
|
719
739
|
refreshPromptAndInject(ctx, updated)
|
|
720
|
-
return { ok: true, message: '已更新个性化设置', changes
|
|
740
|
+
return { ok: true, message: '已更新个性化设置', changes }
|
|
721
741
|
} catch (err) {
|
|
722
742
|
return { ok: false, message: String(err.message || err) }
|
|
723
743
|
}
|
|
@@ -741,16 +761,19 @@ export async function apply(ctx) {
|
|
|
741
761
|
return await loadConfig()
|
|
742
762
|
},
|
|
743
763
|
async updateConfig(newConfig) {
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
764
|
+
// 与 HTTP 保存共用同一套校验;失败时抛错给服务调用方
|
|
765
|
+
const { patch, errors } = sanitizeConfig(newConfig)
|
|
766
|
+
if (Object.keys(errors).length > 0) {
|
|
767
|
+
throw new Error('配置校验失败:' + formatFieldErrors(errors))
|
|
768
|
+
}
|
|
769
|
+
return await commitConfig(current => ({ ...current, ...patch }))
|
|
747
770
|
},
|
|
748
771
|
async getSystemPrompt() {
|
|
749
772
|
const config = await loadConfig()
|
|
750
773
|
return compilePrompt(config)
|
|
751
774
|
},
|
|
752
775
|
async resetConfig() {
|
|
753
|
-
return await
|
|
776
|
+
return await commitConfig(() => ({ ...DEFAULT_CONFIG }))
|
|
754
777
|
}
|
|
755
778
|
})
|
|
756
779
|
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// lib/config.mjs — dsh-soul 配置层(纯函数模块,无任何依赖)
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// - 默认配置与合法取值常量(DEFAULT_CONFIG / STYLE_VALUES / TRAIT_VALUES / LANGUAGE_VALUES)
|
|
5
|
+
// - 旧版本配置迁移(migrateConfig)
|
|
6
|
+
// - 外部输入校验(sanitizeConfig):字段白名单 + 类型断言 + 长度上限 + 枚举校验
|
|
7
|
+
//
|
|
8
|
+
// index.mjs 的所有配置写路径(HTTP 保存 / /soul 命令 / set_persona 工具 / soulConfig 服务)
|
|
9
|
+
// 共用同一套校验;本模块可被 scripts/verify-config.mjs 独立加载测试。
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CONFIG = {
|
|
12
|
+
enabled: true,
|
|
13
|
+
nickname: '',
|
|
14
|
+
// 关于你:用户职业 / 用户介绍
|
|
15
|
+
occupation: '',
|
|
16
|
+
bio: '',
|
|
17
|
+
// 回复风格和语调(v0.2.0 起合并为单一选项)
|
|
18
|
+
style: 'professional',
|
|
19
|
+
// 特质:标题和列表 / 表情符号(default=默认,more=增强,less=减弱)
|
|
20
|
+
headingLists: 'default',
|
|
21
|
+
emoji: 'default',
|
|
22
|
+
language: 'zh',
|
|
23
|
+
customInstructions: ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 合法的回复风格和语调取值(供工具参数校验)
|
|
27
|
+
export const STYLE_VALUES = ['professional', 'casual', 'humorous', 'roast', 'efficient']
|
|
28
|
+
|
|
29
|
+
// 特质合法取值
|
|
30
|
+
export const TRAIT_VALUES = ['default', 'more', 'less']
|
|
31
|
+
|
|
32
|
+
// 输出语言合法取值
|
|
33
|
+
export const LANGUAGE_VALUES = ['zh', 'en']
|
|
34
|
+
|
|
35
|
+
// 文本字段长度上限:防止超长文本撑爆 system prompt(同时缩小提示词注入面)。
|
|
36
|
+
// 超限一律拒绝、不做静默截断,避免改写用户输入。
|
|
37
|
+
export const FIELD_LIMITS = {
|
|
38
|
+
nickname: 50,
|
|
39
|
+
occupation: 50,
|
|
40
|
+
bio: 500,
|
|
41
|
+
customInstructions: 2000
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 需做长度校验的自由文本字段
|
|
45
|
+
const TEXT_FIELDS = ['nickname', 'occupation', 'bio', 'customInstructions']
|
|
46
|
+
|
|
47
|
+
// v0.1.x 配置使用 style + tone 两个字段;v0.2.0 起合并为单一 style(回复风格和语调)。
|
|
48
|
+
// 迁移映射:组合命中用组合表,否则退回旧 style 表,再否则用默认值。
|
|
49
|
+
const LEGACY_STYLE_TONE_MAP = {
|
|
50
|
+
'professional+formal': 'professional',
|
|
51
|
+
'casual+neutral': 'casual',
|
|
52
|
+
'humorous+informal': 'humorous'
|
|
53
|
+
}
|
|
54
|
+
const LEGACY_STYLE_MAP = {
|
|
55
|
+
professional: 'professional',
|
|
56
|
+
casual: 'casual',
|
|
57
|
+
friendly: 'casual',
|
|
58
|
+
humorous: 'humorous',
|
|
59
|
+
academic: 'professional'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 迁移并清洗磁盘上的历史配置:补齐默认值、合并旧 style+tone、丢弃废弃字段、
|
|
63
|
+
// 特质脏数据回退为默认值。
|
|
64
|
+
export function migrateConfig(raw) {
|
|
65
|
+
const config = { ...DEFAULT_CONFIG, ...raw }
|
|
66
|
+
if (raw && typeof raw.style === 'string' && typeof raw.tone === 'string') {
|
|
67
|
+
const combo = `${raw.style}+${raw.tone}`
|
|
68
|
+
config.style = LEGACY_STYLE_TONE_MAP[combo] || LEGACY_STYLE_MAP[raw.style] || DEFAULT_CONFIG.style
|
|
69
|
+
delete config.tone
|
|
70
|
+
}
|
|
71
|
+
// 人设预设 / 示例指令功能已在 v0.2.0 移除,丢弃历史残留字段
|
|
72
|
+
delete config.presets
|
|
73
|
+
delete config.tone
|
|
74
|
+
delete config.examples
|
|
75
|
+
// 特质字段校验:脏数据回退为默认值
|
|
76
|
+
if (!TRAIT_VALUES.includes(config.headingLists)) config.headingLists = 'default'
|
|
77
|
+
if (!TRAIT_VALUES.includes(config.emoji)) config.emoji = 'default'
|
|
78
|
+
return config
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 校验外部传入的配置补丁,返回 { patch, errors }:
|
|
82
|
+
// - patch:通过校验的字段补丁(仅含白名单字段),调用方将其合并到当前配置
|
|
83
|
+
// - errors:被拒绝字段的原因表(字段名 -> 说明);非空时调用方应整单拒绝本次写入
|
|
84
|
+
//
|
|
85
|
+
// 规则:
|
|
86
|
+
// - 白名单:未知字段静默丢弃,不再落盘
|
|
87
|
+
// - 类型:enabled 必须为布尔;文本字段必须为字符串;枚举字段必须命中合法取值
|
|
88
|
+
// - 长度:文本字段超过 FIELD_LIMITS 即拒绝(不截断)
|
|
89
|
+
// - 文本字段保存前 trim 首尾空白,空串视为清除该字段
|
|
90
|
+
export function sanitizeConfig(raw) {
|
|
91
|
+
const patch = {}
|
|
92
|
+
const errors = {}
|
|
93
|
+
|
|
94
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
95
|
+
return { patch, errors: { _: '配置必须是一个 JSON 对象' } }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ('enabled' in raw) {
|
|
99
|
+
if (typeof raw.enabled === 'boolean') {
|
|
100
|
+
patch.enabled = raw.enabled
|
|
101
|
+
} else {
|
|
102
|
+
errors.enabled = '必须为布尔值(true / false)'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const key of TEXT_FIELDS) {
|
|
107
|
+
if (!(key in raw)) continue
|
|
108
|
+
const value = raw[key]
|
|
109
|
+
if (typeof value !== 'string') {
|
|
110
|
+
errors[key] = '必须为字符串'
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const text = value.trim()
|
|
114
|
+
if (text.length > FIELD_LIMITS[key]) {
|
|
115
|
+
errors[key] = `长度超过上限 ${FIELD_LIMITS[key]} 字符(当前 ${text.length})`
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
patch[key] = text
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ENUM_CHECKS = [
|
|
122
|
+
['style', STYLE_VALUES, '回复风格和语调'],
|
|
123
|
+
['headingLists', TRAIT_VALUES, '特质·标题和列表'],
|
|
124
|
+
['emoji', TRAIT_VALUES, '特质·表情符号'],
|
|
125
|
+
['language', LANGUAGE_VALUES, '输出语言']
|
|
126
|
+
]
|
|
127
|
+
for (const [key, values, label] of ENUM_CHECKS) {
|
|
128
|
+
if (!(key in raw)) continue
|
|
129
|
+
const value = raw[key]
|
|
130
|
+
if (typeof value === 'string' && values.includes(value)) {
|
|
131
|
+
patch[key] = value
|
|
132
|
+
} else {
|
|
133
|
+
errors[key] = `${label}必须是 ${values.join(' / ')} 之一`
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { patch, errors }
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-soul",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "为 DeepSeek Harness 添加个性化设置",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"dsh-plugin",
|
|
8
|
+
"dsh-soul",
|
|
8
9
|
"deepseek",
|
|
9
10
|
"deepseek-harness",
|
|
10
11
|
"personalization",
|
|
11
12
|
"persona",
|
|
12
13
|
"system-prompt",
|
|
13
|
-
"ai-agent",
|
|
14
14
|
"ai-assistant",
|
|
15
15
|
"customization"
|
|
16
16
|
],
|
|
17
17
|
"type": "module",
|
|
18
18
|
"main": "index.mjs",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"verify": "node scripts/verify-config.mjs"
|
|
21
|
+
},
|
|
19
22
|
"license": "MIT",
|
|
20
23
|
"repository": {
|
|
21
24
|
"type": "git",
|
|
@@ -48,8 +51,10 @@
|
|
|
48
51
|
},
|
|
49
52
|
"files": [
|
|
50
53
|
"index.mjs",
|
|
54
|
+
"lib",
|
|
51
55
|
"client",
|
|
52
56
|
"cordis.patch.yml",
|
|
57
|
+
"scripts",
|
|
53
58
|
"README.md",
|
|
54
59
|
"README_EN.md"
|
|
55
60
|
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// scripts/verify-config.mjs — 配置层纯函数自检(npm run verify)
|
|
2
|
+
//
|
|
3
|
+
// 覆盖 lib/config.mjs 的 migrateConfig / sanitizeConfig:
|
|
4
|
+
// - 旧版本 style+tone 迁移、废弃字段清理、特质脏数据回退
|
|
5
|
+
// - 白名单 / 类型 / 长度 / 枚举校验的接受与拒绝路径
|
|
6
|
+
// 零依赖,直接 `node scripts/verify-config.mjs` 运行。
|
|
7
|
+
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_CONFIG,
|
|
11
|
+
FIELD_LIMITS,
|
|
12
|
+
migrateConfig,
|
|
13
|
+
sanitizeConfig
|
|
14
|
+
} from '../lib/config.mjs'
|
|
15
|
+
|
|
16
|
+
let passed = 0
|
|
17
|
+
function check(name, fn) {
|
|
18
|
+
fn()
|
|
19
|
+
passed++
|
|
20
|
+
console.log(` ✓ ${name}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log('migrateConfig')
|
|
24
|
+
|
|
25
|
+
check('非对象输入返回默认配置', () => {
|
|
26
|
+
assert.deepEqual(migrateConfig(null), DEFAULT_CONFIG)
|
|
27
|
+
assert.deepEqual(migrateConfig(undefined), DEFAULT_CONFIG)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
check('正常配置原样保留并补齐默认值', () => {
|
|
31
|
+
const config = migrateConfig({ enabled: false, nickname: '小明', style: 'roast', language: 'en' })
|
|
32
|
+
assert.equal(config.enabled, false)
|
|
33
|
+
assert.equal(config.nickname, '小明')
|
|
34
|
+
assert.equal(config.style, 'roast')
|
|
35
|
+
assert.equal(config.language, 'en')
|
|
36
|
+
assert.equal(config.headingLists, 'default')
|
|
37
|
+
assert.equal(config.emoji, 'default')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
check('v0.1.x style+tone 组合迁移', () => {
|
|
41
|
+
assert.equal(migrateConfig({ style: 'professional', tone: 'formal' }).style, 'professional')
|
|
42
|
+
assert.equal(migrateConfig({ style: 'casual', tone: 'neutral' }).style, 'casual')
|
|
43
|
+
assert.equal(migrateConfig({ style: 'humorous', tone: 'informal' }).style, 'humorous')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
check('v0.1.x 旧 style 名迁移(friendly→casual,academic→professional)', () => {
|
|
47
|
+
assert.equal(migrateConfig({ style: 'friendly', tone: 'neutral' }).style, 'casual')
|
|
48
|
+
assert.equal(migrateConfig({ style: 'academic', tone: 'formal' }).style, 'professional')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
check('废弃字段 tone / presets / examples 清理', () => {
|
|
52
|
+
const config = migrateConfig({ nickname: 'x', tone: 'formal', presets: [1], examples: 'y' })
|
|
53
|
+
assert.equal('tone' in config, false)
|
|
54
|
+
assert.equal('presets' in config, false)
|
|
55
|
+
assert.equal('examples' in config, false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
check('特质脏数据回退为默认值', () => {
|
|
59
|
+
const config = migrateConfig({ headingLists: 'always', emoji: 1 })
|
|
60
|
+
assert.equal(config.headingLists, 'default')
|
|
61
|
+
assert.equal(config.emoji, 'default')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
console.log('sanitizeConfig')
|
|
65
|
+
|
|
66
|
+
check('合法补丁全部通过', () => {
|
|
67
|
+
const { patch, errors } = sanitizeConfig({
|
|
68
|
+
enabled: false,
|
|
69
|
+
nickname: '小明',
|
|
70
|
+
occupation: '工程师',
|
|
71
|
+
bio: '写代码的',
|
|
72
|
+
style: 'humorous',
|
|
73
|
+
headingLists: 'more',
|
|
74
|
+
emoji: 'less',
|
|
75
|
+
language: 'en',
|
|
76
|
+
customInstructions: '保持简洁'
|
|
77
|
+
})
|
|
78
|
+
assert.deepEqual(errors, {})
|
|
79
|
+
assert.equal(patch.enabled, false)
|
|
80
|
+
assert.equal(patch.nickname, '小明')
|
|
81
|
+
assert.equal(patch.occupation, '工程师')
|
|
82
|
+
assert.equal(patch.style, 'humorous')
|
|
83
|
+
assert.equal(patch.headingLists, 'more')
|
|
84
|
+
assert.equal(patch.emoji, 'less')
|
|
85
|
+
assert.equal(patch.language, 'en')
|
|
86
|
+
assert.equal(patch.customInstructions, '保持简洁')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
check('未知字段静默丢弃(不进入 patch,也不报错)', () => {
|
|
90
|
+
const { patch, errors } = sanitizeConfig({ nickname: 'x', hacked: 'junk', presets: [1] })
|
|
91
|
+
assert.deepEqual(errors, {})
|
|
92
|
+
assert.deepEqual(patch, { nickname: 'x' })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
check('类型错误被拒绝:enabled 非布尔、文本非字符串', () => {
|
|
96
|
+
const { errors } = sanitizeConfig({ enabled: 'yes', nickname: 123 })
|
|
97
|
+
assert.ok(errors.enabled)
|
|
98
|
+
assert.ok(errors.nickname)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
check('枚举错误被拒绝:style / headingLists / emoji / language', () => {
|
|
102
|
+
const { errors } = sanitizeConfig({ style: 'hacker', headingLists: 'always', emoji: 'max', language: 'jp' })
|
|
103
|
+
assert.ok(errors.style)
|
|
104
|
+
assert.ok(errors.headingLists)
|
|
105
|
+
assert.ok(errors.emoji)
|
|
106
|
+
assert.ok(errors.language)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
check('超长文本被拒绝且不截断', () => {
|
|
110
|
+
const { errors, patch } = sanitizeConfig({
|
|
111
|
+
nickname: '名'.repeat(FIELD_LIMITS.nickname + 1),
|
|
112
|
+
customInstructions: 'a'.repeat(FIELD_LIMITS.customInstructions + 1)
|
|
113
|
+
})
|
|
114
|
+
assert.ok(errors.nickname)
|
|
115
|
+
assert.ok(errors.customInstructions)
|
|
116
|
+
assert.equal('nickname' in patch, false)
|
|
117
|
+
assert.equal('customInstructions' in patch, false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
check('长度上限边界值通过', () => {
|
|
121
|
+
const { errors } = sanitizeConfig({
|
|
122
|
+
nickname: '名'.repeat(FIELD_LIMITS.nickname),
|
|
123
|
+
bio: 'b'.repeat(FIELD_LIMITS.bio),
|
|
124
|
+
customInstructions: 'a'.repeat(FIELD_LIMITS.customInstructions)
|
|
125
|
+
})
|
|
126
|
+
assert.deepEqual(errors, {})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
check('文本字段首尾空白被 trim,空白串视为清除', () => {
|
|
130
|
+
const { patch, errors } = sanitizeConfig({ nickname: ' 小明 ', bio: ' ' })
|
|
131
|
+
assert.deepEqual(errors, {})
|
|
132
|
+
assert.equal(patch.nickname, '小明')
|
|
133
|
+
assert.equal(patch.bio, '')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
check('非对象输入整体拒绝', () => {
|
|
137
|
+
assert.ok(sanitizeConfig([1, 2]).errors._)
|
|
138
|
+
assert.ok(sanitizeConfig('x').errors._)
|
|
139
|
+
assert.ok(sanitizeConfig(null).errors._)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
console.log(`\n全部通过:${passed} 项检查`)
|