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/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
- // 预览系统提示词端点已在 v0.2.0 移除(提示词预览功能下线)
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 personas = config.personas || {}
511
- for (const personaName of Object.keys(personas).sort()) {
512
- const entry = personas[personaName]
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, personas: config.personas || {}, activeName: findActivePersonaName(config) })
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, personas: updated.personas || {}, activeName: findActivePersonaName(updated) })
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
- if (!personaName || !hasOwn(current.personas || {}, personaName)) {
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
- const { patch } = sanitizeConfig(pickPersonaValues(current.personas[personaName]))
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, personas: updated.personas || {}, activeName: findActivePersonaName(updated) })
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 hlName = t.traitNames[config.headingLists] || config.headingLists
851
- const emojiName = t.traitNames[config.emoji] || config.emoji
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}${t.headingListsLabel}=${hlName},${t.emojiLabel}=${emojiName}\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}`
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
- if (!hasOwn(config.personas || {}, personaName)) {
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(config.personas[personaName]))
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
- const personas = config.personas || {}
917
- const names = Object.keys(personas).sort()
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 = personas[personaName]
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
- return `${personaName === activeName ? '✔ ' : '• '}${personaName} | ${t.styleLabel}=${styleText} | ${t.nicknameLabel}=${nickText}`
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
- // 特质:标题和列表 / 表情符号(default=默认,more=增强,less=减弱)
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, '光轨光带粗细']
@@ -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.6.2",
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",