dsh-soul 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/README_EN.md +12 -7
- package/assets/icon.svg +21 -0
- package/client/index.mjs +296 -48
- package/index.mjs +159 -24
- package/lib/config.mjs +15 -3
- package/lib/personas.mjs +172 -0
- package/package.json +7 -4
- package/scripts/verify-config.mjs +438 -0
- package/scripts/verify-nav-icon.mjs +451 -0
package/index.mjs
CHANGED
|
@@ -24,6 +24,7 @@ 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,7 @@ import {
|
|
|
32
33
|
sanitizePersonaName
|
|
33
34
|
} from './lib/config.mjs'
|
|
34
35
|
import { createInjectionSource } from './lib/injection.mjs'
|
|
36
|
+
import { isBuiltinPersona, mergePersonas, personaMatches, resolvePersona } from './lib/personas.mjs'
|
|
35
37
|
|
|
36
38
|
const name = 'soul'
|
|
37
39
|
|
|
@@ -69,6 +71,14 @@ const PROMPT_TEXT = {
|
|
|
69
71
|
more: '在回复中使用较多表情符号',
|
|
70
72
|
less: '尽量减少使用表情符号'
|
|
71
73
|
},
|
|
74
|
+
tables: {
|
|
75
|
+
more: '在呈现对比、多字段信息或结构化内容时优先使用表格',
|
|
76
|
+
less: '避免使用表格,改用列表或段落文本呈现'
|
|
77
|
+
},
|
|
78
|
+
replyLength: {
|
|
79
|
+
concise: '回答尽量简洁,直击要点,不展开无关内容',
|
|
80
|
+
detailed: '回答尽量详尽,充分展开背景、步骤与推理过程'
|
|
81
|
+
},
|
|
72
82
|
replyLanguage: {
|
|
73
83
|
zh: '必须使用简体中文回复',
|
|
74
84
|
en: '必须使用英语(English)回复'
|
|
@@ -116,6 +126,14 @@ const PROMPT_TEXT = {
|
|
|
116
126
|
more: 'Use emojis fairly often in replies',
|
|
117
127
|
less: 'Keep emoji usage to a minimum'
|
|
118
128
|
},
|
|
129
|
+
tables: {
|
|
130
|
+
more: 'Prefer tables when presenting comparisons, multi-field or structured information',
|
|
131
|
+
less: 'Avoid tables; use lists or paragraph text instead'
|
|
132
|
+
},
|
|
133
|
+
replyLength: {
|
|
134
|
+
concise: 'Keep answers concise: hit the key points and skip unrelated elaboration',
|
|
135
|
+
detailed: 'Give detailed answers: fully expand on background, steps and reasoning'
|
|
136
|
+
},
|
|
119
137
|
replyLanguage: {
|
|
120
138
|
zh: '必须使用简体中文回复',
|
|
121
139
|
en: 'You must reply in English.'
|
|
@@ -155,12 +173,16 @@ function buildUserProfile(config, T) {
|
|
|
155
173
|
return lines.join('\n')
|
|
156
174
|
}
|
|
157
175
|
|
|
158
|
-
// 行为块:回复风格和语调 +
|
|
176
|
+
// 行为块:回复风格和语调 + 特质(标题和列表 / 表情符号 / 表格)+ 回复长度 + 输出语言。
|
|
177
|
+
// 缺项(如 tables=default、replyLength=normal)在文案表中没有对应键,直接跳过、不产生文本,
|
|
178
|
+
// 因此默认配置编译出的提示词与新增这两个维度之前完全一致。
|
|
159
179
|
function buildBehavior(config, T) {
|
|
160
180
|
const parts = []
|
|
161
181
|
if (config.style && T.styles[config.style]) parts.push(`${T.styleLabel}${T.styles[config.style]}`)
|
|
162
182
|
if (config.headingLists && T.headingLists[config.headingLists]) parts.push(T.headingLists[config.headingLists])
|
|
163
183
|
if (config.emoji && T.emoji[config.emoji]) parts.push(T.emoji[config.emoji])
|
|
184
|
+
if (config.tables && T.tables[config.tables]) parts.push(T.tables[config.tables])
|
|
185
|
+
if (config.replyLength && T.replyLength[config.replyLength]) parts.push(T.replyLength[config.replyLength])
|
|
164
186
|
if (config.language && T.replyLanguage[config.language]) parts.push(T.replyLanguage[config.language])
|
|
165
187
|
return parts.join(T.joiner)
|
|
166
188
|
}
|
|
@@ -442,7 +464,50 @@ function registerRoutes(ctx) {
|
|
|
442
464
|
}
|
|
443
465
|
})
|
|
444
466
|
|
|
445
|
-
//
|
|
467
|
+
// 预览「未保存的编辑」编译出的 system prompt:以已保存配置为底,用请求体中
|
|
468
|
+
// 通过校验的字段覆盖后编译。
|
|
469
|
+
//
|
|
470
|
+
// **不落盘、不注入会话** —— 纯只读旁路,供设置在保存前确认真正会被注入的内容。
|
|
471
|
+
// v0.1.x 曾有同类端点,随 v0.2.0 精简重构下线;此处恢复并修掉旧实现的一个疏漏:
|
|
472
|
+
// 旧版直接展开请求体({ ...current, ...draft })未经校验,而这里与保存路径共用
|
|
473
|
+
// 同一套 sanitizeConfig —— 非法字段既不参与预览、也不会落盘,并通过 invalid
|
|
474
|
+
// 回报给客户端,避免出现「预览悄悄沿用了旧值」这种看不出原因的偏差。
|
|
475
|
+
wsCtx.webServer.register({
|
|
476
|
+
kind: 'exact',
|
|
477
|
+
path: '/api/soul/prompt/preview',
|
|
478
|
+
handler: async (req, res) => {
|
|
479
|
+
const send = (status, body) => {
|
|
480
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
481
|
+
res.end(JSON.stringify(body))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
if (req.method !== 'POST') {
|
|
486
|
+
send(405, { ok: false, error: 'Method not allowed' })
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const parsed = await readJsonBody(req)
|
|
491
|
+
if (!parsed.ok) {
|
|
492
|
+
send(parsed.status, { ok: false, error: parsed.error })
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const { patch, errors } = sanitizeConfig(parsed.body)
|
|
497
|
+
const current = await loadConfig()
|
|
498
|
+
const draft = { ...current, ...patch }
|
|
499
|
+
|
|
500
|
+
send(200, {
|
|
501
|
+
ok: true,
|
|
502
|
+
prompt: compilePrompt(draft),
|
|
503
|
+
enabled: draft.enabled,
|
|
504
|
+
invalid: Object.keys(errors)
|
|
505
|
+
})
|
|
506
|
+
} catch (err) {
|
|
507
|
+
send(500, { ok: false, error: String(err.message || err) })
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
})
|
|
446
511
|
|
|
447
512
|
// 重置配置
|
|
448
513
|
wsCtx.webServer.register({
|
|
@@ -505,16 +570,21 @@ function hasOwn(obj, key) {
|
|
|
505
570
|
return Object.prototype.hasOwnProperty.call(obj, key)
|
|
506
571
|
}
|
|
507
572
|
|
|
508
|
-
//
|
|
573
|
+
// 计算与当前活动配置匹配的预设名(多个命中时取名称排序最前)。
|
|
574
|
+
// 匹配语义是「部分覆盖」而非全字段相等,理由见 lib/personas.mjs 的 personaMatches。
|
|
509
575
|
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
|
|
576
|
+
const view = mergePersonas(config.personas)
|
|
577
|
+
for (const personaName of Object.keys(view).sort()) {
|
|
578
|
+
if (personaMatches(view[personaName], config)) return personaName
|
|
514
579
|
}
|
|
515
580
|
return null
|
|
516
581
|
}
|
|
517
582
|
|
|
583
|
+
// 预设库的对外视图(内置 + 用户)与当前命中项,供三个路由的响应复用。
|
|
584
|
+
function personaLibrary(config) {
|
|
585
|
+
return { personas: mergePersonas(config.personas), activeName: findActivePersonaName(config) }
|
|
586
|
+
}
|
|
587
|
+
|
|
518
588
|
// 人设预设 HTTP 路由:列表 / 保存 / 使用 / 删除。
|
|
519
589
|
// 保存与删除仅变更 personas 库(diffKeys 跳过该字段),不会触发会话注入;
|
|
520
590
|
// 使用会变更活动配置字段,走常规 promptChanged 门控注入。
|
|
@@ -536,7 +606,7 @@ function registerPersonaRoutes(ctx) {
|
|
|
536
606
|
return
|
|
537
607
|
}
|
|
538
608
|
const config = await loadConfig()
|
|
539
|
-
send(res, 200, { ok: true,
|
|
609
|
+
send(res, 200, { ok: true, ...personaLibrary(config) })
|
|
540
610
|
} catch (err) {
|
|
541
611
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
542
612
|
}
|
|
@@ -563,12 +633,18 @@ function registerPersonaRoutes(ctx) {
|
|
|
563
633
|
send(res, 400, { ok: false, error: `预设名称无效(1-${PERSONA_NAME_MAX} 个字符)` })
|
|
564
634
|
return
|
|
565
635
|
}
|
|
636
|
+
// 内置名一律不可写入:内置预设只存在于代码里,若允许同名落盘,合并视图会
|
|
637
|
+
// 遮蔽它,用户就会得到一个「保存了却看不见、也删不掉」的幽灵条目。
|
|
638
|
+
if (isBuiltinPersona(personaName)) {
|
|
639
|
+
send(res, 400, { ok: false, error: `「${personaName}」是内置预设,请换一个名称` })
|
|
640
|
+
return
|
|
641
|
+
}
|
|
566
642
|
const { config: updated } = await commitConfig((current) => {
|
|
567
643
|
const personas = clonePersonas(current.personas)
|
|
568
644
|
personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
|
|
569
645
|
return { ...current, personas }
|
|
570
646
|
})
|
|
571
|
-
send(res, 200, { ok: true,
|
|
647
|
+
send(res, 200, { ok: true, ...personaLibrary(updated) })
|
|
572
648
|
} catch (err) {
|
|
573
649
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
574
650
|
}
|
|
@@ -592,12 +668,15 @@ function registerPersonaRoutes(ctx) {
|
|
|
592
668
|
}
|
|
593
669
|
const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
|
|
594
670
|
const current = await loadConfig()
|
|
595
|
-
|
|
671
|
+
// 内置与用户预设共用同一条应用路径(resolvePersona 先内置后用户)
|
|
672
|
+
const entry = personaName ? resolvePersona(personaName, current.personas) : null
|
|
673
|
+
if (!entry) {
|
|
596
674
|
send(res, 400, { ok: false, error: `预设不存在:${personaName || '(空)'}` })
|
|
597
675
|
return
|
|
598
676
|
}
|
|
599
|
-
//
|
|
600
|
-
|
|
677
|
+
// 内置预设已在代码里写过一遍;用户预设保存时也校验过。此处统一再过一遍
|
|
678
|
+
// 白名单,防御手改 config.json 注入的异常数据。
|
|
679
|
+
const { patch } = sanitizeConfig(pickPersonaValues(entry))
|
|
601
680
|
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
|
|
602
681
|
if (promptChanged.length > 0) {
|
|
603
682
|
refreshPromptAndInject(ctx, updated)
|
|
@@ -625,6 +704,13 @@ function registerPersonaRoutes(ctx) {
|
|
|
625
704
|
return
|
|
626
705
|
}
|
|
627
706
|
const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
|
|
707
|
+
// 内置预设不可删除。这一层必须挡在「磁盘库是否存在」之前:内置项本来就不在
|
|
708
|
+
// 磁盘库里,但手改文件可能留下同名残留,那种条目已被合并视图遮蔽、删它没有
|
|
709
|
+
// 意义,而调用方真正想删的是内置项——直接拒绝,语义才唯一。
|
|
710
|
+
if (personaName && isBuiltinPersona(personaName)) {
|
|
711
|
+
send(res, 400, { ok: false, error: `「${personaName}」是内置预设,无法删除` })
|
|
712
|
+
return
|
|
713
|
+
}
|
|
628
714
|
const current = await loadConfig()
|
|
629
715
|
if (!personaName || !hasOwn(current.personas || {}, personaName)) {
|
|
630
716
|
send(res, 400, { ok: false, error: `预设不存在:${personaName || '(空)'}` })
|
|
@@ -635,7 +721,7 @@ function registerPersonaRoutes(ctx) {
|
|
|
635
721
|
delete personas[personaName]
|
|
636
722
|
return { ...c, personas }
|
|
637
723
|
})
|
|
638
|
-
send(res, 200, { ok: true,
|
|
724
|
+
send(res, 200, { ok: true, ...personaLibrary(updated) })
|
|
639
725
|
} catch (err) {
|
|
640
726
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
641
727
|
}
|
|
@@ -667,11 +753,19 @@ const COMMAND_MESSAGES = {
|
|
|
667
753
|
traitsLabel: '特质',
|
|
668
754
|
headingListsLabel: '标题和列表',
|
|
669
755
|
emojiLabel: '表情符号',
|
|
756
|
+
tablesLabel: '表格',
|
|
757
|
+
replyLengthLabel: '回复长度',
|
|
670
758
|
traitNames: {
|
|
671
759
|
default: '默认',
|
|
672
760
|
more: '增强',
|
|
673
761
|
less: '减弱'
|
|
674
762
|
},
|
|
763
|
+
replyLengthNames: {
|
|
764
|
+
concise: '简洁',
|
|
765
|
+
normal: '适中',
|
|
766
|
+
detailed: '详尽'
|
|
767
|
+
},
|
|
768
|
+
listSep: ',',
|
|
675
769
|
trailLabel: '输入框光轨',
|
|
676
770
|
trailLine: (status, color, speedName, widthName) => `${status}(${color} / ${speedName} / ${widthName})`,
|
|
677
771
|
trailSpeedNames: {
|
|
@@ -697,6 +791,10 @@ const COMMAND_MESSAGES = {
|
|
|
697
791
|
confirmDone: (detail) => `✅ 已应用人设变更:${detail}`,
|
|
698
792
|
rejectDone: '✅ 已拒绝待确认的人设变更',
|
|
699
793
|
saveDone: (name) => `✅ 已保存预设「${name}」`,
|
|
794
|
+
// 内置预设(随插件发布、不落盘):不可用同名保存,也不可删除
|
|
795
|
+
builtinName: '内置',
|
|
796
|
+
builtinSaveBlocked: (name) => `「${name}」是内置预设,请换一个名称`,
|
|
797
|
+
builtinDeleteBlocked: (name) => `「${name}」是内置预设,无法删除`,
|
|
700
798
|
useDone: (name) => `✅ 已应用预设「${name}」`,
|
|
701
799
|
useUnchanged: (name) => `ℹ 预设「${name}」与当前配置一致,无需变更`,
|
|
702
800
|
personaMissing: (name) => `预设「${name}」不存在`,
|
|
@@ -706,7 +804,7 @@ const COMMAND_MESSAGES = {
|
|
|
706
804
|
saveUsage: '用法:/soul save <名称>(1-30 个字符)',
|
|
707
805
|
useUsage: '用法:/soul use <名称>',
|
|
708
806
|
delUsage: '用法:/soul del <名称>',
|
|
709
|
-
setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
807
|
+
setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
710
808
|
unknownField: '未知配置项',
|
|
711
809
|
invalidBoolean: (key) => `${key} 取值必须为 true / false`,
|
|
712
810
|
setNoChanges: '配置无变化,未做修改',
|
|
@@ -736,11 +834,19 @@ const COMMAND_MESSAGES = {
|
|
|
736
834
|
traitsLabel: 'Traits',
|
|
737
835
|
headingListsLabel: 'Headings & lists',
|
|
738
836
|
emojiLabel: 'Emoji',
|
|
837
|
+
tablesLabel: 'Tables',
|
|
838
|
+
replyLengthLabel: 'Reply length',
|
|
739
839
|
traitNames: {
|
|
740
840
|
default: 'Default',
|
|
741
841
|
more: 'More',
|
|
742
842
|
less: 'Less'
|
|
743
843
|
},
|
|
844
|
+
replyLengthNames: {
|
|
845
|
+
concise: 'Concise',
|
|
846
|
+
normal: 'Balanced',
|
|
847
|
+
detailed: 'Detailed'
|
|
848
|
+
},
|
|
849
|
+
listSep: ', ',
|
|
744
850
|
trailLabel: 'Composer light trail',
|
|
745
851
|
trailLine: (status, color, speedName, widthName) => `${status} (${color} / ${speedName} / ${widthName})`,
|
|
746
852
|
trailSpeedNames: {
|
|
@@ -766,6 +872,9 @@ const COMMAND_MESSAGES = {
|
|
|
766
872
|
confirmDone: (detail) => `✅ Persona changes applied: ${detail}`,
|
|
767
873
|
rejectDone: '✅ Pending persona proposal discarded',
|
|
768
874
|
saveDone: (name) => `✅ Persona "${name}" saved`,
|
|
875
|
+
builtinName: 'built-in',
|
|
876
|
+
builtinSaveBlocked: (name) => `"${name}" is a built-in persona — pick another name`,
|
|
877
|
+
builtinDeleteBlocked: (name) => `"${name}" is a built-in persona and cannot be deleted`,
|
|
769
878
|
useDone: (name) => `✅ Persona "${name}" applied`,
|
|
770
879
|
useUnchanged: (name) => `ℹ Persona "${name}" matches the current config`,
|
|
771
880
|
personaMissing: (name) => `Persona "${name}" does not exist`,
|
|
@@ -775,7 +884,7 @@ const COMMAND_MESSAGES = {
|
|
|
775
884
|
saveUsage: 'Usage: /soul save <name> (1-30 chars)',
|
|
776
885
|
useUsage: 'Usage: /soul use <name>',
|
|
777
886
|
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)',
|
|
887
|
+
setUsage: 'Usage: /soul set key=value ... (fields: enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
779
888
|
unknownField: 'Unknown field',
|
|
780
889
|
invalidBoolean: (key) => `${key} must be true or false`,
|
|
781
890
|
setNoChanges: 'No config changes to apply',
|
|
@@ -847,8 +956,12 @@ function registerCommands(ctx) {
|
|
|
847
956
|
const occupation = config.occupation || t.notSet
|
|
848
957
|
const bio = config.bio || t.notSet
|
|
849
958
|
const styleName = t.styleNames[config.style] || config.style || t.notSet
|
|
850
|
-
const
|
|
851
|
-
|
|
959
|
+
const traitsLine = [
|
|
960
|
+
`${t.headingListsLabel}=${t.traitNames[config.headingLists] || config.headingLists}`,
|
|
961
|
+
`${t.emojiLabel}=${t.traitNames[config.emoji] || config.emoji}`,
|
|
962
|
+
`${t.tablesLabel}=${t.traitNames[config.tables] || config.tables}`,
|
|
963
|
+
`${t.replyLengthLabel}=${t.replyLengthNames[config.replyLength] || config.replyLength}`
|
|
964
|
+
].join(t.listSep)
|
|
852
965
|
const instructions = config.customInstructions || t.notSet
|
|
853
966
|
const personaCount = Object.keys(config.personas || {}).length
|
|
854
967
|
const pendingLine = pendingPersonaProposal ? `\n${t.pendingHint}` : ''
|
|
@@ -861,7 +974,7 @@ function registerCommands(ctx) {
|
|
|
861
974
|
|
|
862
975
|
return {
|
|
863
976
|
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}${
|
|
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}`
|
|
865
978
|
}
|
|
866
979
|
}
|
|
867
980
|
|
|
@@ -888,6 +1001,9 @@ function registerCommands(ctx) {
|
|
|
888
1001
|
if (first === 'save') {
|
|
889
1002
|
const personaName = sanitizePersonaName(rest)
|
|
890
1003
|
if (!personaName) return { kind: 'error', text: t.saveUsage }
|
|
1004
|
+
if (isBuiltinPersona(personaName)) {
|
|
1005
|
+
return { kind: 'error', text: t.builtinSaveBlocked(personaName) }
|
|
1006
|
+
}
|
|
891
1007
|
await commitConfig((current) => {
|
|
892
1008
|
const personas = clonePersonas(current.personas)
|
|
893
1009
|
personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
|
|
@@ -899,11 +1015,13 @@ function registerCommands(ctx) {
|
|
|
899
1015
|
if (first === 'use') {
|
|
900
1016
|
const personaName = sanitizePersonaName(rest)
|
|
901
1017
|
if (!personaName) return { kind: 'error', text: t.useUsage }
|
|
902
|
-
|
|
1018
|
+
// 内置与用户预设共用同一条应用路径(resolvePersona 先内置后用户)
|
|
1019
|
+
const entry = resolvePersona(personaName, config.personas)
|
|
1020
|
+
if (!entry) {
|
|
903
1021
|
return { kind: 'error', text: t.personaMissing(personaName) }
|
|
904
1022
|
}
|
|
905
1023
|
// 预设值保存时已校验;此处再过一遍白名单,防御手改文件等异常数据
|
|
906
|
-
const { patch } = sanitizeConfig(pickPersonaValues(
|
|
1024
|
+
const { patch } = sanitizeConfig(pickPersonaValues(entry))
|
|
907
1025
|
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
|
|
908
1026
|
if (changed.length === 0) {
|
|
909
1027
|
return { kind: 'success', text: t.useUnchanged(personaName) }
|
|
@@ -913,17 +1031,19 @@ function registerCommands(ctx) {
|
|
|
913
1031
|
}
|
|
914
1032
|
|
|
915
1033
|
if (first === 'list') {
|
|
916
|
-
|
|
917
|
-
const
|
|
1034
|
+
// 与 Web UI 一致:列出合并视图(内置 + 用户),并标出内置项
|
|
1035
|
+
const view = mergePersonas(config.personas)
|
|
1036
|
+
const names = Object.keys(view).sort()
|
|
918
1037
|
if (names.length === 0) {
|
|
919
1038
|
return { kind: 'success', text: `${t.listTitle}\n${t.listEmpty}` }
|
|
920
1039
|
}
|
|
921
1040
|
const activeName = findActivePersonaName(config)
|
|
922
1041
|
const lines = names.map((personaName) => {
|
|
923
|
-
const entry =
|
|
1042
|
+
const entry = view[personaName]
|
|
924
1043
|
const styleText = STYLE_VALUES.includes(entry.style) ? (t.styleNames[entry.style] || entry.style) : (entry.style || '-')
|
|
925
1044
|
const nickText = entry.nickname || '-'
|
|
926
|
-
|
|
1045
|
+
const mark = entry.builtin ? ` [${t.builtinName}]` : ''
|
|
1046
|
+
return `${personaName === activeName ? '✔ ' : '• '}${personaName}${mark} | ${t.styleLabel}=${styleText} | ${t.nicknameLabel}=${nickText}`
|
|
927
1047
|
})
|
|
928
1048
|
return { kind: 'success', text: `${t.listTitle} [${names.length}]\n${lines.join('\n')}` }
|
|
929
1049
|
}
|
|
@@ -931,6 +1051,9 @@ function registerCommands(ctx) {
|
|
|
931
1051
|
if (first === 'del' || first === 'delete' || first === 'rm') {
|
|
932
1052
|
const personaName = sanitizePersonaName(rest)
|
|
933
1053
|
if (!personaName) return { kind: 'error', text: t.delUsage }
|
|
1054
|
+
if (isBuiltinPersona(personaName)) {
|
|
1055
|
+
return { kind: 'error', text: t.builtinDeleteBlocked(personaName) }
|
|
1056
|
+
}
|
|
934
1057
|
if (!hasOwn(config.personas || {}, personaName)) {
|
|
935
1058
|
return { kind: 'error', text: t.personaMissing(personaName) }
|
|
936
1059
|
}
|
|
@@ -1082,6 +1205,16 @@ function registerTools(ctx) {
|
|
|
1082
1205
|
enum: TRAIT_VALUES,
|
|
1083
1206
|
description: '特质·表情符号:default=默认,more=增强(使用较多表情符号),less=减弱(尽量减少使用表情符号)。'
|
|
1084
1207
|
},
|
|
1208
|
+
tables: {
|
|
1209
|
+
type: 'string',
|
|
1210
|
+
enum: TRAIT_VALUES,
|
|
1211
|
+
description: '特质·表格:default=默认,more=增强(呈现对比、多字段或结构化信息时优先使用表格),less=减弱(避免表格,改用列表或段落文本)。'
|
|
1212
|
+
},
|
|
1213
|
+
replyLength: {
|
|
1214
|
+
type: 'string',
|
|
1215
|
+
enum: REPLY_LENGTH_VALUES,
|
|
1216
|
+
description: '回复长度偏好:concise=简洁(直击要点、不展开),normal=适中(不额外约束),detailed=详尽(充分展开背景、步骤与推理)。'
|
|
1217
|
+
},
|
|
1085
1218
|
customInstructions: { type: 'string', description: '额外的自定义指令,用于覆盖或补充当前人设。' },
|
|
1086
1219
|
},
|
|
1087
1220
|
output: {
|
|
@@ -1107,6 +1240,8 @@ function registerTools(ctx) {
|
|
|
1107
1240
|
language: { type: 'string' },
|
|
1108
1241
|
headingLists: { type: 'string' },
|
|
1109
1242
|
emoji: { type: 'string' },
|
|
1243
|
+
tables: { type: 'string' },
|
|
1244
|
+
replyLength: { type: 'string' },
|
|
1110
1245
|
customInstructions: { type: 'string' },
|
|
1111
1246
|
},
|
|
1112
1247
|
},
|
package/lib/config.mjs
CHANGED
|
@@ -16,9 +16,12 @@ export const DEFAULT_CONFIG = {
|
|
|
16
16
|
bio: '',
|
|
17
17
|
// 回复风格和语调(v0.2.0 起合并为单一选项)
|
|
18
18
|
style: 'professional',
|
|
19
|
-
// 特质:标题和列表 /
|
|
19
|
+
// 特质:标题和列表 / 表情符号 / 表格(default=默认,more=增强,less=减弱)
|
|
20
20
|
headingLists: 'default',
|
|
21
21
|
emoji: 'default',
|
|
22
|
+
tables: 'default',
|
|
23
|
+
// 回复长度偏好:concise=简洁,normal=适中(不产生任何提示词文案),detailed=详尽
|
|
24
|
+
replyLength: 'normal',
|
|
22
25
|
language: 'zh',
|
|
23
26
|
customInstructions: '',
|
|
24
27
|
// set_persona 工具确认模式:开启后 Agent 的人设修改返回待确认提议而不落盘
|
|
@@ -33,9 +36,14 @@ export const DEFAULT_CONFIG = {
|
|
|
33
36
|
// 合法的回复风格和语调取值(供工具参数校验)
|
|
34
37
|
export const STYLE_VALUES = ['professional', 'casual', 'humorous', 'roast', 'efficient']
|
|
35
38
|
|
|
36
|
-
//
|
|
39
|
+
// 特质合法取值(标题和列表 / 表情符号 / 表格 共用三档)
|
|
37
40
|
export const TRAIT_VALUES = ['default', 'more', 'less']
|
|
38
41
|
|
|
42
|
+
// 回复长度偏好合法取值。
|
|
43
|
+
// normal(适中)刻意不产生任何提示词文案 —— 与特质的 default 同理,
|
|
44
|
+
// 代表「不额外约束」,保证它作为默认值时现有用户的行为零变化。
|
|
45
|
+
export const REPLY_LENGTH_VALUES = ['concise', 'normal', 'detailed']
|
|
46
|
+
|
|
39
47
|
// 输出语言合法取值
|
|
40
48
|
export const LANGUAGE_VALUES = ['zh', 'en']
|
|
41
49
|
|
|
@@ -66,7 +74,7 @@ const TEXT_FIELDS = ['nickname', 'occupation', 'bio', 'customInstructions']
|
|
|
66
74
|
// ==================== 人设预设 ====================
|
|
67
75
|
|
|
68
76
|
// 人设预设覆盖的配置字段(enabled 为全局开关、不进入预设)
|
|
69
|
-
export const PERSONA_FIELDS = ['nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions']
|
|
77
|
+
export const PERSONA_FIELDS = ['nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
|
|
70
78
|
|
|
71
79
|
// 人设预设名称上限
|
|
72
80
|
export const PERSONA_NAME_MAX = 30
|
|
@@ -127,6 +135,8 @@ export function migrateConfig(raw) {
|
|
|
127
135
|
// 特质字段校验:脏数据回退为默认值
|
|
128
136
|
if (!TRAIT_VALUES.includes(config.headingLists)) config.headingLists = 'default'
|
|
129
137
|
if (!TRAIT_VALUES.includes(config.emoji)) config.emoji = 'default'
|
|
138
|
+
if (!TRAIT_VALUES.includes(config.tables)) config.tables = DEFAULT_CONFIG.tables
|
|
139
|
+
if (!REPLY_LENGTH_VALUES.includes(config.replyLength)) config.replyLength = DEFAULT_CONFIG.replyLength
|
|
130
140
|
// 确认模式:非布尔脏数据回退为默认值
|
|
131
141
|
if (typeof config.requireToolConfirmation !== 'boolean') config.requireToolConfirmation = DEFAULT_CONFIG.requireToolConfirmation
|
|
132
142
|
// 光轨字段脏数据回退:布尔 / 枚举 / 6 位十六进制颜色
|
|
@@ -214,6 +224,8 @@ export function sanitizeConfig(raw) {
|
|
|
214
224
|
['style', STYLE_VALUES, '回复风格和语调'],
|
|
215
225
|
['headingLists', TRAIT_VALUES, '特质·标题和列表'],
|
|
216
226
|
['emoji', TRAIT_VALUES, '特质·表情符号'],
|
|
227
|
+
['tables', TRAIT_VALUES, '特质·表格'],
|
|
228
|
+
['replyLength', REPLY_LENGTH_VALUES, '回复长度偏好'],
|
|
217
229
|
['language', LANGUAGE_VALUES, '输出语言'],
|
|
218
230
|
['trailSpeed', TRAIL_SPEED_VALUES, '光轨流动速度'],
|
|
219
231
|
['trailWidth', TRAIL_WIDTH_VALUES, '光轨光带粗细']
|
package/lib/personas.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// lib/personas.mjs — dsh-soul 内置人设预设(纯数据 + 纯函数,无任何依赖)
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// - 内置人设清单(BUILTIN_PERSONAS)
|
|
5
|
+
// - 用户预设库与内置预设的合并(mergePersonas / resolvePersona)
|
|
6
|
+
//
|
|
7
|
+
// 三条设计约定(verify-config.mjs 逐条固化,改动时勿破坏):
|
|
8
|
+
//
|
|
9
|
+
// 1) **只声明要覆盖的字段**。预设是「部分覆盖」语义:应用时只写入预设实际
|
|
10
|
+
// 声明的键,未声明的保持用户当前值。所以内置预设一律省略
|
|
11
|
+
// `nickname` / `occupation` / `bio`——那是「关于你」的用户自有信息,
|
|
12
|
+
// 预设去写它们等于把用户的昵称职业清空。
|
|
13
|
+
//
|
|
14
|
+
// 2) **不声明 `language`**。输出语言属于用户偏好而非人设的一部分;内置预设若
|
|
15
|
+
// 写死 zh,英文用户应用一次就会被强行切回中文。省略即「不动它」。
|
|
16
|
+
//
|
|
17
|
+
// 3) **内置预设不进磁盘**。它们只存在于本文件,磁盘上的 `config.personas`
|
|
18
|
+
// 仅存用户自己保存的预设。好处有三:升级时内置内容自动更新(不会被用户
|
|
19
|
+
// 的旧副本覆盖)、用户无法删除或篡改内置项、`$DSH_HOME/soul-config.json`
|
|
20
|
+
// 保持干净。写入路径(保存 / 删除)对内置名一律拒绝,因此不存在「删了又
|
|
21
|
+
// 回来」的困惑状态。
|
|
22
|
+
//
|
|
23
|
+
// 名称冲突规则:**内置优先**。磁盘上若残留同名条目(手改文件或历史数据),
|
|
24
|
+
// 合并时会被内置内容遮蔽,且该名字在 UI 上不可删除——不变量是「内置名永远指向
|
|
25
|
+
// 内置内容」。保存路径同样拒绝内置名,用户想微调就换个名字存。
|
|
26
|
+
|
|
27
|
+
import { PERSONA_FIELDS } from './config.mjs'
|
|
28
|
+
|
|
29
|
+
// ==================== 内置人设 ====================
|
|
30
|
+
//
|
|
31
|
+
// 每条只写与默认值不同的字段。默认值见 lib/config.mjs 的 DEFAULT_CONFIG:
|
|
32
|
+
// style=professional / headingLists=default / emoji=default / tables=default
|
|
33
|
+
// / replyLength=normal(normal 与 default 均不产生任何提示词文案)
|
|
34
|
+
//
|
|
35
|
+
// customInstructions 是内置人设的主要载体:枚举字段只能表达「更啰嗦 / 更简洁」
|
|
36
|
+
// 这类粗粒度倾向,人格化的行为差异要靠自由文本。写法上只描述**行为规则**
|
|
37
|
+
// (先问什么、先给什么、按什么顺序展开),避免与 style 档位的文案重复。
|
|
38
|
+
|
|
39
|
+
export const BUILTIN_PERSONAS = {
|
|
40
|
+
'苏格拉底式提问者': {
|
|
41
|
+
style: 'professional',
|
|
42
|
+
headingLists: 'less',
|
|
43
|
+
customInstructions:
|
|
44
|
+
'用提问推进对话,而不是直接给答案。' +
|
|
45
|
+
'先用自己的话复述你对问题的理解,确认无误后再提出 1-2 个能暴露关键假设的追问;' +
|
|
46
|
+
'追问要具体、指向可验证的事实,不要问「你确定吗」这类空泛问题。' +
|
|
47
|
+
'只有当用户明确要求结论,或已经追问过两轮时,才给出答案——此时要一并说明结论依赖哪些前提。'
|
|
48
|
+
},
|
|
49
|
+
'极简主义者': {
|
|
50
|
+
style: 'efficient',
|
|
51
|
+
headingLists: 'less',
|
|
52
|
+
emoji: 'less',
|
|
53
|
+
tables: 'less',
|
|
54
|
+
replyLength: 'concise',
|
|
55
|
+
customInstructions:
|
|
56
|
+
'只给结论和必要依据,砍掉一切铺垫、客套与重复。' +
|
|
57
|
+
'能用一句话说清就不要用三句;不主动扩展话题,也不追加未被问及的注意事项。' +
|
|
58
|
+
'若信息不足以回答,只问一个最关键的问题,不要罗列一串待确认项。'
|
|
59
|
+
},
|
|
60
|
+
'资深架构师': {
|
|
61
|
+
style: 'professional',
|
|
62
|
+
headingLists: 'more',
|
|
63
|
+
tables: 'more',
|
|
64
|
+
replyLength: 'detailed',
|
|
65
|
+
customInstructions:
|
|
66
|
+
'以系统设计视角回答:先说清约束、假设与规模量级,再给方案。' +
|
|
67
|
+
'至少对比两条技术路线,用表格列出权衡维度(复杂度、可维护性、性能、迁移成本、团队门槛)。' +
|
|
68
|
+
'主动指出边界条件、失败模式与长期演进风险;' +
|
|
69
|
+
'涉及取舍时给出明确推荐并说明理由,不要以「都可以,看需求」收尾。'
|
|
70
|
+
},
|
|
71
|
+
'教学型讲解者': {
|
|
72
|
+
style: 'casual',
|
|
73
|
+
headingLists: 'more',
|
|
74
|
+
replyLength: 'detailed',
|
|
75
|
+
customInstructions:
|
|
76
|
+
'按「是什么 → 为什么 → 怎么用」的顺序讲解,先建立直觉再引入术语。' +
|
|
77
|
+
'开头用一个具体例子或类比,结尾用一句话总结关键点。' +
|
|
78
|
+
'遇到专业名词先解释含义,不要假设用户已掌握前置知识;' +
|
|
79
|
+
'但每个概念只讲一遍,不要为了周全而反复换说法重复同一件事。'
|
|
80
|
+
},
|
|
81
|
+
'严格代码审阅者': {
|
|
82
|
+
style: 'professional',
|
|
83
|
+
headingLists: 'more',
|
|
84
|
+
tables: 'more',
|
|
85
|
+
emoji: 'less',
|
|
86
|
+
replyLength: 'detailed',
|
|
87
|
+
customInstructions:
|
|
88
|
+
'按代码审阅者的标准挑问题,按严重度分组:正确性 / 安全 / 性能 / 可维护性 / 风格。' +
|
|
89
|
+
'每条指出具体位置、触发条件与修复方向,不要只说「这里可能有问题」。' +
|
|
90
|
+
'优先报告会导致错误结果、数据损坏或安全风险的问题。' +
|
|
91
|
+
'没有发现问题时,明确说明检查过哪些方面,不要为凑数编造无关痛痒的意见。'
|
|
92
|
+
},
|
|
93
|
+
'头脑风暴伙伴': {
|
|
94
|
+
style: 'casual',
|
|
95
|
+
headingLists: 'more',
|
|
96
|
+
customInstructions:
|
|
97
|
+
'先发散再收敛:一次性给出若干方向明显不同的方案,而不是同一方案的变体。' +
|
|
98
|
+
'每个方案用一句话说清核心思路与主要代价。' +
|
|
99
|
+
'发散阶段不要否定任何想法;给出方案后再标出你个人最看好的一个及理由。' +
|
|
100
|
+
'避免只给显而易见的选项,至少包含一个不常规但有可行性的思路。'
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ==================== 合并与查询 ====================
|
|
105
|
+
|
|
106
|
+
// 预设条目的元数据键:不属于人设字段,参与比较时须剔除
|
|
107
|
+
const PERSONA_META_KEYS = new Set(['updatedAt', 'builtin'])
|
|
108
|
+
|
|
109
|
+
// 预设实际声明的人设字段名(只保留 PERSONA_FIELDS 白名单内的键)
|
|
110
|
+
export function declaredPersonaKeys(entry) {
|
|
111
|
+
return Object.keys(entry || {}).filter(
|
|
112
|
+
(key) => !PERSONA_META_KEYS.has(key) && PERSONA_FIELDS.includes(key)
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 预设是否与当前活动配置匹配(★ 标记的判据)。
|
|
117
|
+
//
|
|
118
|
+
// 预设是「部分覆盖」语义——应用时只写它声明过的字段,所以判定也必须只比较
|
|
119
|
+
// 声明过的字段。若按 PERSONA_FIELDS 全量比较,内置预设会永远匹配不上:它们
|
|
120
|
+
// 刻意省略 nickname / occupation / bio(用户自有信息)与 language(输出语言属于
|
|
121
|
+
// 用户偏好),省略的含义是「不动它」,而不是「要求它是空」。
|
|
122
|
+
//
|
|
123
|
+
// 一个字段都没声明的条目视为不匹配——否则它会匹配任何配置。
|
|
124
|
+
export function personaMatches(entry, config) {
|
|
125
|
+
const keys = declaredPersonaKeys(entry)
|
|
126
|
+
if (keys.length === 0) return false
|
|
127
|
+
return keys.every((key) => entry[key] === config[key])
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 内置人设名(按定义顺序返回;UI 列表用它把内置项排在前面的展示区)
|
|
131
|
+
export function builtinPersonaNames() {
|
|
132
|
+
return Object.keys(BUILTIN_PERSONAS)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 是否为内置人设名(保存与删除路径的准入判据)
|
|
136
|
+
export function isBuiltinPersona(name) {
|
|
137
|
+
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(BUILTIN_PERSONAS, name)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 合并用户预设库与内置预设,返回给前端的展示视图。
|
|
141
|
+
// 内置条目额外带 `builtin: true` —— 前端据此渲染「内置」标记并隐藏删除按钮;
|
|
142
|
+
// 用户预设不带该字段。内置优先,同名用户条目被遮蔽(见文件头约定 3)。
|
|
143
|
+
export function mergePersonas(userPersonas) {
|
|
144
|
+
const out = {}
|
|
145
|
+
for (const name of builtinPersonaNames()) {
|
|
146
|
+
out[name] = { ...BUILTIN_PERSONAS[name], builtin: true }
|
|
147
|
+
}
|
|
148
|
+
if (userPersonas && typeof userPersonas === 'object' && !Array.isArray(userPersonas)) {
|
|
149
|
+
for (const name of Object.keys(userPersonas)) {
|
|
150
|
+
if (isBuiltinPersona(name)) continue
|
|
151
|
+
out[name] = { ...userPersonas[name] }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 按名称取预设条目(先内置后用户),不存在返回 null。
|
|
158
|
+
export function resolvePersona(name, userPersonas) {
|
|
159
|
+
if (typeof name !== 'string') return null
|
|
160
|
+
if (isBuiltinPersona(name)) return { ...BUILTIN_PERSONAS[name] }
|
|
161
|
+
if (userPersonas && Object.prototype.hasOwnProperty.call(userPersonas, name)) {
|
|
162
|
+
return { ...userPersonas[name] }
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 预设声明的字段数(UI 摘要与测试用;内置预设字段少于用户预设,因为前者
|
|
168
|
+
// 刻意省略用户自有信息)。
|
|
169
|
+
export function declaredFieldCount(persona) {
|
|
170
|
+
if (!persona || typeof persona !== 'object') return 0
|
|
171
|
+
return Object.keys(persona).filter((key) => key !== 'updatedAt' && key !== 'builtin').length
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-soul",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "为 DeepSeek Harness
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "为 DeepSeek Harness 添加个性化设置。",
|
|
5
|
+
"icon": "./assets/icon.svg",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"dsh",
|
|
7
8
|
"dsh-plugin",
|
|
@@ -17,9 +18,10 @@
|
|
|
17
18
|
"type": "module",
|
|
18
19
|
"main": "index.mjs",
|
|
19
20
|
"scripts": {
|
|
20
|
-
"verify": "node scripts/verify-config.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs",
|
|
21
|
+
"verify": "node scripts/verify-config.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs && node scripts/verify-nav-icon.mjs",
|
|
21
22
|
"verify:compat": "node scripts/verify-compat.mjs",
|
|
22
|
-
"verify:trail": "node scripts/verify-trail.mjs"
|
|
23
|
+
"verify:trail": "node scripts/verify-trail.mjs",
|
|
24
|
+
"verify:nav-icon": "node scripts/verify-nav-icon.mjs"
|
|
23
25
|
},
|
|
24
26
|
"license": "MIT",
|
|
25
27
|
"repository": {
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"index.mjs",
|
|
56
58
|
"lib",
|
|
57
59
|
"client",
|
|
60
|
+
"assets",
|
|
58
61
|
"cordis.patch.yml",
|
|
59
62
|
"scripts",
|
|
60
63
|
"README.md",
|