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/client/index.mjs CHANGED
@@ -3,11 +3,12 @@
3
3
  // 在设置页面添加「个性化」栏目:
4
4
  // - 启用/禁用个性化设置
5
5
  // - 「关于你」(昵称/职业/介绍)、回复风格和语调、特质、输出语言、自定义指令
6
- // - 人设预设:保存当前为预设、一键使用(★ 标记当前匹配项)、删除
6
+ // - 人设预设:6 个内置人设(随插件提供、不可删除,行尾标「内置」)+ 保存当前为预设、一键使用(★ 标记当前匹配项)、删除
7
7
  // - Agent 工具:set_persona 确认模式开关
8
8
  // - 输入框光轨:Agent 回复中时输入框边框的流光动效(颜色 / 速度 / 粗细 + 实时示例)
9
9
  // - dirty 检测(无改动禁用保存)、统一 toast 提示
10
- // - 查看当前生效提示词(只读)与字符数
10
+ // - 提示词预览(只读):无未保存编辑时显示「当前生效提示词」,有编辑时改为编译草稿
11
+ // —— 展示保存后真正会注入的内容,并在保存/重置/应用预设后自动刷新
11
12
  //
12
13
  // 光轨另注册到 conversation.input.overlay 槽位(输入框卡片内部),
13
14
  // 运行时按会话 running 状态在卡片上挂载/隐藏 SVG 环,见 SoulTrail。
@@ -47,7 +48,7 @@ window.__ModuleLoader__.load({
47
48
  // -------------------------------------------------------------------------
48
49
 
49
50
  const NS = 'soul'
50
- const VERSION = '0.6.2'
51
+ const VERSION = '0.7.0'
51
52
  const INITIAL = {
52
53
  enabled: true,
53
54
  nickname: '',
@@ -56,6 +57,8 @@ window.__ModuleLoader__.load({
56
57
  style: 'professional',
57
58
  headingLists: 'default',
58
59
  emoji: 'default',
60
+ tables: 'default',
61
+ replyLength: 'normal',
59
62
  language: 'zh',
60
63
  customInstructions: '',
61
64
  // set_persona 确认模式(v0.5.0)
@@ -346,6 +349,8 @@ window.__ModuleLoader__.load({
346
349
  s.style = config.style
347
350
  s.headingLists = config.headingLists || 'default'
348
351
  s.emoji = config.emoji || 'default'
352
+ s.tables = config.tables || 'default'
353
+ s.replyLength = config.replyLength || 'normal'
349
354
  s.language = config.language || 'zh'
350
355
  s.customInstructions = config.customInstructions
351
356
  s.requireToolConfirmation = config.requireToolConfirmation === true
@@ -462,6 +467,14 @@ window.__ModuleLoader__.load({
462
467
  return payload
463
468
  }
464
469
 
470
+ // 预览「未保存的编辑」编译出的提示词:POST 草稿字段,服务端以已保存配置为底
471
+ // 覆盖后编译。不落盘、不注入 —— 返回的 invalid 列出未通过校验、因而未计入
472
+ // 预览的字段名,供界面如实提示,避免用户以为改动已生效。
473
+ async previewPrompt(draft) {
474
+ const payload = await this.postJSON('/api/soul/prompt/preview', draft)
475
+ return payload
476
+ }
477
+
465
478
  async dispose() {
466
479
  this.disposed = true
467
480
  }
@@ -528,7 +541,8 @@ window.__ModuleLoader__.load({
528
541
  '.soul-persona-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px dashed var(--dsw-alias-border-l2);font-size:12px}',
529
542
  '.soul-persona-name{color:var(--dsw-alias-label-primary);font-weight:500;white-space:nowrap}',
530
543
  '.soul-persona-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
531
- '.soul-persona-actions{display:flex;gap:10px;white-space:nowrap}',
544
+ '.soul-persona-actions{display:flex;gap:10px;white-space:nowrap;align-items:center}',
545
+ '.soul-persona-badge{font-size:11px;line-height:16px;padding:0 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
532
546
  '.soul-persona-save{display:flex;gap:8px}',
533
547
  '.soul-persona-save input[type=text]{flex:1}',
534
548
  '.soul-prompt-link{background:none;border:none;padding:0;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-decoration:underline}',
@@ -632,6 +646,10 @@ window.__ModuleLoader__.load({
632
646
  'hint.headingLists': '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中标题和列表的使用程度。',
633
647
  'field.emoji': '表情符号',
634
648
  'hint.emoji': '在回复风格和语调的基础上选择额外的自定义特质项。控制表情符号的使用程度。',
649
+ 'field.tables': '表格',
650
+ 'hint.tables': '在回复风格和语调的基础上选择额外的自定义特质项。控制回答中表格的使用程度。',
651
+ 'field.replyLength': '回复长度',
652
+ 'hint.replyLength': '控制回答的篇幅。选「适中」时不额外约束,由 Agent 自行判断。',
635
653
  'field.language': '输出语言',
636
654
  'hint.language': '设置 Agent 回复你使用的语言,同时影响 /soul 命令的输出语言。',
637
655
  'field.instructions': '自定义指令',
@@ -644,6 +662,8 @@ window.__ModuleLoader__.load({
644
662
  'personas.delete': '删除',
645
663
  'personas.confirmDelete': '确定删除预设「{name}」?',
646
664
  'personas.empty': '暂无人设预设,保存当前配置后可一键切换',
665
+ 'personas.builtin': '内置',
666
+ 'personas.hint': '内置预设随插件提供,不可删除;保存当前配置可创建自己的预设',
647
667
  'style.professional': '专业严谨',
648
668
  'style.casual': '轻松自然',
649
669
  'style.humorous': '幽默风趣',
@@ -655,6 +675,23 @@ window.__ModuleLoader__.load({
655
675
  'trait.emoji.default': '默认',
656
676
  'trait.emoji.more': '增强(使用较多表情符号)',
657
677
  'trait.emoji.less': '减弱(尽量减少使用表情符号)',
678
+ 'trait.tables.default': '默认',
679
+ 'trait.tables.more': '增强(优先用表格呈现对比与多字段信息)',
680
+ 'trait.tables.less': '减弱(避免使用表格,改用列表或段落)',
681
+ 'trait.replyLength.concise': '简洁(只讲要点,不展开)',
682
+ 'trait.replyLength.normal': '适中(不额外约束)',
683
+ 'trait.replyLength.detailed': '详尽(充分展开背景与推理)',
684
+ // 预设行摘要用的短标签(trait.* 的完整说明太长,一行放不下)
685
+ 'meta.replyLength.concise': '简洁',
686
+ 'meta.replyLength.detailed': '详尽',
687
+ 'meta.headingLists.more': '多列表',
688
+ 'meta.headingLists.less': '少列表',
689
+ 'meta.emoji.more': '多表情',
690
+ 'meta.emoji.less': '少表情',
691
+ 'meta.tables.more': '多用表格',
692
+ 'meta.tables.less': '少用表格',
693
+ 'meta.language.zh': '中文',
694
+ 'meta.language.en': '英文',
658
695
  'group.tool': 'Agent 工具',
659
696
  'field.toolConfirm': '人设变更需确认',
660
697
  'hint.toolConfirm': '开启后,Agent 通过 set_persona 工具做出的修改不会立即生效,需在会话中使用 /soul confirm 确认或 /soul reject 拒绝。',
@@ -679,6 +716,9 @@ window.__ModuleLoader__.load({
679
716
  'prompt.loading': '提示词加载中...',
680
717
  'prompt.empty': '(个性化已禁用,提示词为空)',
681
718
  'prompt.chars': '当前字符数:{n}',
719
+ 'prompt.titleDraft': '保存后将生效的提示词',
720
+ 'prompt.summaryDraft': '{n} 字符 · 未保存',
721
+ 'prompt.invalid': '以下字段未通过校验,未计入本次预览:{fields}',
682
722
  'toast.saveFailed': '❌ 保存失败,请检查配置',
683
723
  'trail.title': '输入框光轨',
684
724
  'trail.enable': '启用输入框光轨',
@@ -723,6 +763,10 @@ window.__ModuleLoader__.load({
723
763
  'hint.headingLists': 'An extra trait layered on top of style & tone. Controls how much answers rely on headings and lists.',
724
764
  'field.emoji': 'Emoji',
725
765
  'hint.emoji': 'An extra trait layered on top of style & tone. Controls how many emojis answers use.',
766
+ 'field.tables': 'Tables',
767
+ 'hint.tables': 'An extra trait layered on top of style & tone. Controls how much answers rely on tables.',
768
+ 'field.replyLength': 'Reply length',
769
+ 'hint.replyLength': 'Controls how long answers are. "Balanced" adds no constraint and lets the agent decide.',
726
770
  'field.language': 'Output language',
727
771
  'hint.language': 'Sets the language the agent replies in; also affects the /soul command output.',
728
772
  'field.instructions': 'Custom instructions',
@@ -735,6 +779,8 @@ window.__ModuleLoader__.load({
735
779
  'personas.delete': 'Delete',
736
780
  'personas.confirmDelete': 'Delete persona "{name}"?',
737
781
  'personas.empty': 'No personas yet — save the current config to switch with one click',
782
+ 'personas.builtin': 'Built-in',
783
+ 'personas.hint': 'Built-in personas ship with the plugin and cannot be deleted; save the current config to create your own',
738
784
  'style.professional': 'Professional',
739
785
  'style.casual': 'Casual',
740
786
  'style.humorous': 'Humorous',
@@ -746,6 +792,23 @@ window.__ModuleLoader__.load({
746
792
  'trait.emoji.default': 'Default',
747
793
  'trait.emoji.more': 'More (frequent emoji usage)',
748
794
  'trait.emoji.less': 'Less (minimal emoji usage)',
795
+ 'trait.tables.default': 'Default',
796
+ 'trait.tables.more': 'More (prefer tables for comparisons and multi-field data)',
797
+ 'trait.tables.less': 'Less (avoid tables; use lists or paragraphs)',
798
+ 'trait.replyLength.concise': 'Concise (key points only)',
799
+ 'trait.replyLength.normal': 'Balanced (no extra constraint)',
800
+ 'trait.replyLength.detailed': 'Detailed (expand background and reasoning)',
801
+ // Short labels for the persona row summary (trait.* full text is too long)
802
+ 'meta.replyLength.concise': 'Concise',
803
+ 'meta.replyLength.detailed': 'Detailed',
804
+ 'meta.headingLists.more': 'More lists',
805
+ 'meta.headingLists.less': 'Fewer lists',
806
+ 'meta.emoji.more': 'More emoji',
807
+ 'meta.emoji.less': 'Less emoji',
808
+ 'meta.tables.more': 'More tables',
809
+ 'meta.tables.less': 'Fewer tables',
810
+ 'meta.language.zh': 'Chinese',
811
+ 'meta.language.en': 'English',
749
812
  'group.tool': 'Agent tools',
750
813
  'field.toolConfirm': 'Require confirmation for persona changes',
751
814
  'hint.toolConfirm': 'When enabled, changes made by the agent through the set_persona tool do not apply immediately — confirm with /soul confirm or reject with /soul reject in the conversation.',
@@ -770,6 +833,9 @@ window.__ModuleLoader__.load({
770
833
  'prompt.loading': 'Loading prompt...',
771
834
  'prompt.empty': '(personalization disabled — the prompt is empty)',
772
835
  'prompt.chars': 'Current character count: {n}',
836
+ 'prompt.titleDraft': 'Prompt after saving',
837
+ 'prompt.summaryDraft': '{n} chars · unsaved',
838
+ 'prompt.invalid': 'These fields failed validation and are excluded from this preview: {fields}',
773
839
  'toast.saveFailed': '❌ Save failed — please check the config',
774
840
  'trail.title': 'Composer light trail',
775
841
  'trail.enable': 'Enable composer light trail',
@@ -811,6 +877,28 @@ window.__ModuleLoader__.load({
811
877
  // 选项取值(label 走词典;取值与 lib/config.mjs 的合法值保持一致)
812
878
  const STYLE_VALUES = ['professional', 'casual', 'humorous', 'roast', 'efficient']
813
879
  const TRAIT_VALUES = ['default', 'more', 'less']
880
+ // 回复长度偏好(与宿主 lib/config.mjs 的 REPLY_LENGTH_VALUES 对齐)
881
+ const REPLY_LENGTH_VALUES = ['concise', 'normal', 'detailed']
882
+ // 输出语言(与宿主 lib/config.mjs 的 LANGUAGE_VALUES 对齐)
883
+ const LANGUAGE_VALUES = ['zh', 'en']
884
+
885
+ // 预设行摘要只显示「与默认值不同」的字段——若把 10 个字段全列出来,每行都是
886
+ // 一长串「默认 / 适中」,反而看不出两个预设到底差在哪。默认值须与宿主
887
+ // lib/config.mjs 的 DEFAULT_CONFIG 保持一致。
888
+ const META_FIELD_VALUES = {
889
+ replyLength: REPLY_LENGTH_VALUES,
890
+ headingLists: TRAIT_VALUES,
891
+ emoji: TRAIT_VALUES,
892
+ tables: TRAIT_VALUES,
893
+ language: LANGUAGE_VALUES
894
+ }
895
+ const META_FIELD_DEFAULTS = {
896
+ replyLength: 'normal',
897
+ headingLists: 'default',
898
+ emoji: 'default',
899
+ tables: 'default',
900
+ language: 'zh'
901
+ }
814
902
  // 输出语言选项(两种 UI 语言下均自解释,不进词典)
815
903
  const LANGUAGE_OPTIONS = [
816
904
  { value: 'zh', label: '中文' },
@@ -818,7 +906,12 @@ window.__ModuleLoader__.load({
818
906
  ]
819
907
 
820
908
  // 表单字段与 store 字段的一一对应(dirty 检测与保存载荷共用)
821
- const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
909
+ const FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth']
910
+
911
+ // 其中真正参与 compilePrompt 的字段子集。
912
+ // 提示词预览只依赖它们:改光轨颜色 / 粗细、切换 set_persona 确认模式都不会改变
913
+ // 编译结果,不该因此重新请求,也不该让预览被标成「未保存」。
914
+ const PROMPT_FIELD_KEYS = ['enabled', 'nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
822
915
 
823
916
  // 提示词小图标:hover 展示说明文字
824
917
  function SoulHint(props) {
@@ -894,7 +987,7 @@ window.__ModuleLoader__.load({
894
987
  // 渲染器按槽位 locale 命名空间注入 t(随语言切换更新);缺失时回退中文
895
988
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
896
989
  const state = useSoulController((state) => state)
897
- const { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, loading, saving, error } = state
990
+ const { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth, personas, activePersona, loading, saving, error } = state
898
991
 
899
992
  const [localEnabled, setLocalEnabled] = React.useState(enabled)
900
993
  const [localNickname, setLocalNickname] = React.useState(nickname || '')
@@ -903,6 +996,8 @@ window.__ModuleLoader__.load({
903
996
  const [localStyle, setLocalStyle] = React.useState(style)
904
997
  const [localHeadingLists, setLocalHeadingLists] = React.useState(headingLists)
905
998
  const [localEmoji, setLocalEmoji] = React.useState(emoji)
999
+ const [localTables, setLocalTables] = React.useState(tables)
1000
+ const [localReplyLength, setLocalReplyLength] = React.useState(replyLength)
906
1001
  const [localLanguage, setLocalLanguage] = React.useState(language || 'zh')
907
1002
  const [localInstructions, setLocalInstructions] = React.useState(customInstructions)
908
1003
  const [localToolConfirm, setLocalToolConfirm] = React.useState(requireToolConfirmation === true)
@@ -916,6 +1011,10 @@ window.__ModuleLoader__.load({
916
1011
  const [promptText, setPromptText] = React.useState('')
917
1012
  const [promptLoading, setPromptLoading] = React.useState(false)
918
1013
  const [promptError, setPromptError] = React.useState(null)
1014
+ // 当前预览的是「未保存的编辑」编译结果(true)还是已生效的提示词(false)
1015
+ const [promptIsDraft, setPromptIsDraft] = React.useState(false)
1016
+ // 未通过校验、因而未计入预览的字段名(服务端返回 invalid)
1017
+ const [promptInvalid, setPromptInvalid] = React.useState([])
919
1018
  const [openSection, setOpenSection] = React.useState(() => {
920
1019
  try {
921
1020
  return globalThis.localStorage?.getItem('dsh-soul:open-section') || 'about'
@@ -942,6 +1041,8 @@ window.__ModuleLoader__.load({
942
1041
  setLocalStyle(style)
943
1042
  setLocalHeadingLists(headingLists)
944
1043
  setLocalEmoji(emoji)
1044
+ setLocalTables(tables)
1045
+ setLocalReplyLength(replyLength)
945
1046
  setLocalLanguage(language || 'zh')
946
1047
  setLocalInstructions(customInstructions)
947
1048
  setLocalToolConfirm(requireToolConfirmation === true)
@@ -949,7 +1050,7 @@ window.__ModuleLoader__.load({
949
1050
  setLocalTrailColor(trailColor || TRAIL_COLOR_FALLBACK)
950
1051
  setLocalTrailSpeed(trailSpeed || 'slow')
951
1052
  setLocalTrailWidth(trailWidth || 'thin')
952
- }, [enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
1053
+ }, [enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth])
953
1054
 
954
1055
  // toast 自动消失
955
1056
  React.useEffect(() => {
@@ -960,16 +1061,36 @@ window.__ModuleLoader__.load({
960
1061
  }, [toast])
961
1062
 
962
1063
  // dirty 检测:本地表单与已保存配置逐字段比较
963
- const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
964
- const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm, trailEnabled: localTrailEnabled, trailColor: localTrailColor, trailSpeed: localTrailSpeed, trailWidth: localTrailWidth }
1064
+ const savedMap = { enabled, nickname, occupation, bio, style, headingLists, emoji, tables, replyLength, language, customInstructions, requireToolConfirmation, trailEnabled, trailColor, trailSpeed, trailWidth }
1065
+ const localMap = { enabled: localEnabled, nickname: localNickname, occupation: localOccupation, bio: localBio, style: localStyle, headingLists: localHeadingLists, emoji: localEmoji, tables: localTables, replyLength: localReplyLength, language: localLanguage, customInstructions: localInstructions, requireToolConfirmation: localToolConfirm, trailEnabled: localTrailEnabled, trailColor: localTrailColor, trailSpeed: localTrailSpeed, trailWidth: localTrailWidth }
965
1066
  const dirty = FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
966
1067
 
1068
+ // 预览是否反映未保存的编辑:只看参与编译的字段(见 PROMPT_FIELD_KEYS)。
1069
+ // 这里用 localMap/savedMap 逐字段比,而不是复用上面的 dirty —— 改光轨颜色会让
1070
+ // dirty 为真,但那不影响提示词,此时预览仍是「已生效内容」,标成草稿反而误导。
1071
+ const promptDirty = PROMPT_FIELD_KEYS.some((key) => savedMap[key] !== localMap[key])
1072
+ // 草稿指纹:仅在影响编译的字段真的变化时才重新预览
1073
+ const promptDraftKey = PROMPT_FIELD_KEYS.map((key) => String(localMap[key])).join('\u0000')
1074
+
1075
+ // 只把参与编译的字段发给预览端点:光轨等字段的校验失败不该出现在提示词预览里
1076
+ const promptDraft = () => {
1077
+ const out = {}
1078
+ for (const key of PROMPT_FIELD_KEYS) out[key] = localMap[key]
1079
+ return out
1080
+ }
1081
+
1082
+ // 有未保存编辑时预览草稿(=保存后真正会注入的内容),否则预览已生效提示词
967
1083
  const loadPrompt = async () => {
1084
+ const asDraft = promptDirty
968
1085
  setPromptLoading(true)
969
1086
  setPromptError(null)
970
1087
  try {
971
- const payload = await controller.fetchPrompt()
1088
+ const payload = asDraft
1089
+ ? await controller.previewPrompt(promptDraft())
1090
+ : await controller.fetchPrompt()
972
1091
  setPromptText(payload.prompt || '')
1092
+ setPromptInvalid(Array.isArray(payload.invalid) ? payload.invalid : [])
1093
+ setPromptIsDraft(asDraft)
973
1094
  } catch (err) {
974
1095
  setPromptError(messageOf(err))
975
1096
  } finally {
@@ -977,11 +1098,8 @@ window.__ModuleLoader__.load({
977
1098
  }
978
1099
  }
979
1100
 
980
- const togglePrompt = async () => {
981
- const next = !showPrompt
982
- setShowPrompt(next)
983
- if (next) await loadPrompt()
984
- }
1101
+ // 展开/收起只切状态;编译由下方 effect 统一驱动(唯一入口,避免重复请求)
1102
+ const togglePrompt = () => setShowPrompt((open) => !open)
985
1103
 
986
1104
  const loadPersonas = async () => {
987
1105
  try {
@@ -1017,6 +1135,28 @@ window.__ModuleLoader__.load({
1017
1135
  }
1018
1136
  }, [controller, dirty, saving])
1019
1137
 
1138
+ // 提示词预览的编译入口(唯一)。
1139
+ // 展开时立即编译一次;此后只要影响编译的字段或脏状态变化,停止 400ms 再编译 —
1140
+ // 边改边看保存后真正会注入的内容。
1141
+ // promptSettled 用来区分「刚展开」与「展开后字段又变了」:否则展开这一动作自身
1142
+ // 会先立即编译一次、再被防抖重复编译一次。
1143
+ // 依赖里刻意不放 loadPrompt(组件体内每次渲染都会重建函数,放进去会无限循环);
1144
+ // 两个依赖项任一变化都会让 effect 带着最新闭包重跑,闭包不会过期。
1145
+ const promptSettled = React.useRef(false)
1146
+ React.useEffect(() => {
1147
+ if (!showPrompt) {
1148
+ promptSettled.current = false
1149
+ return
1150
+ }
1151
+ if (!promptSettled.current) {
1152
+ promptSettled.current = true
1153
+ void loadPrompt()
1154
+ return
1155
+ }
1156
+ const timer = setTimeout(() => { void loadPrompt() }, 400)
1157
+ return () => clearTimeout(timer)
1158
+ }, [showPrompt, promptDraftKey, promptDirty])
1159
+
1020
1160
  const handleSave = async () => {
1021
1161
  const payload = await controller.saveConfig(localMap)
1022
1162
  // saveConfig 失败时内部已记录 error 并返回 undefined:给出失败提示,而非误报「无变化」
@@ -1028,13 +1168,15 @@ window.__ModuleLoader__.load({
1028
1168
  text: Array.isArray(payload.changed) && payload.changed.length > 0 ? t('toast.saved') : t('toast.noChanges'),
1029
1169
  kind: 'success'
1030
1170
  })
1031
- if (showPrompt) await loadPrompt()
1171
+ // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1172
+ // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1032
1173
  }
1033
1174
 
1034
1175
  const handleReset = async () => {
1035
1176
  await controller.resetConfig()
1036
1177
  setToast({ text: t('toast.reset'), kind: 'success' })
1037
- if (showPrompt) await loadPrompt()
1178
+ // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1179
+ // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1038
1180
  }
1039
1181
 
1040
1182
  const handleSavePersona = async () => {
@@ -1055,7 +1197,8 @@ window.__ModuleLoader__.load({
1055
1197
  const payload = await controller.usePersona(name)
1056
1198
  await loadPersonas()
1057
1199
  setToast({ text: payload && payload.unchanged ? t('toast.personaUnchanged') : t('toast.personaUsed'), kind: 'success' })
1058
- if (showPrompt) await loadPrompt()
1200
+ // 预览刷新交由下方 effect 统一驱动(保存 / 重置 / 应用预设都会改变
1201
+ // promptDraftKey 或 promptDirty,effect 自会带上最新闭包重编译)
1059
1202
  } catch (err) {
1060
1203
  setToast({ text: messageOf(err), kind: 'error' })
1061
1204
  }
@@ -1075,24 +1218,44 @@ window.__ModuleLoader__.load({
1075
1218
  const styleOptions = STYLE_VALUES.map((value) => ({ value, label: t(`style.${value}`) }))
1076
1219
  const headingListsOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.headingLists.${value}`) }))
1077
1220
  const emojiOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.emoji.${value}`) }))
1078
- const personaNames = personas ? Object.keys(personas).sort() : null
1221
+ const tablesOptions = TRAIT_VALUES.map((value) => ({ value, label: t(`trait.tables.${value}`) }))
1222
+ const replyLengthOptions = REPLY_LENGTH_VALUES.map((value) => ({ value, label: t(`trait.replyLength.${value}`) }))
1223
+ // 内置预设排在前面(沿用宿主返回的定义顺序,便于发现),用户预设紧随其后。
1224
+ // Array.prototype.sort 自 ES2019 起保证稳定,同类项因此维持原有插入顺序。
1225
+ const personaNames = personas
1226
+ ? Object.keys(personas).sort((a, b) => {
1227
+ const aBuiltin = personas[a] && personas[a].builtin ? 0 : 1
1228
+ const bBuiltin = personas[b] && personas[b].builtin ? 0 : 1
1229
+ return aBuiltin - bBuiltin
1230
+ })
1231
+ : null
1079
1232
  const aboutFilled = [localNickname, localOccupation, localBio].filter(Boolean).length
1080
1233
  const aboutSummary = aboutFilled > 0 ? `${aboutFilled}/3 ${t('accordion.filled')}` : t('accordion.empty')
1081
1234
  const traitsSummary = t(`style.${localStyle}`)
1082
1235
  const personasSummary = personas === null ? t('status.loading') : `${Object.keys(personas).length} ${t('accordion.personas')}`
1083
1236
  const toolSummary = localToolConfirm ? t('accordion.on') : t('accordion.off')
1084
1237
  const promptSummary = promptText.length > 0
1085
- ? t('prompt.summaryChars', { n: String(promptText.length) })
1238
+ ? t(promptIsDraft ? 'prompt.summaryDraft' : 'prompt.summaryChars', { n: String(promptText.length) })
1086
1239
  : (enabled ? t('prompt.summaryEnabled') : t('prompt.summaryDisabled'))
1087
1240
  const toggleSection = (section) => () => {
1088
1241
  setOpenSection(current => current === section ? null : section)
1089
1242
  }
1090
1243
 
1244
+ // 预设行摘要:风格 + 昵称 + 所有「偏离默认值」的维度。
1245
+ // 内置预设刻意不声明昵称等用户自有信息,所以它们只显示风格与维度差异——
1246
+ // 这恰好也是比较两个预设时最需要看到的信息。
1091
1247
  const personaRowMeta = (entry) => {
1248
+ const row = entry || {}
1092
1249
  const parts = []
1093
- if (entry && STYLE_VALUES.includes(entry.style)) parts.push(t(`style.${entry.style}`))
1094
- else if (entry && entry.style) parts.push(entry.style)
1095
- if (entry && entry.nickname) parts.push(entry.nickname)
1250
+ if (STYLE_VALUES.includes(row.style)) parts.push(t(`style.${row.style}`))
1251
+ else if (row.style) parts.push(row.style)
1252
+ if (row.nickname) parts.push(row.nickname)
1253
+ for (const [field, allowed] of Object.entries(META_FIELD_VALUES)) {
1254
+ const value = row[field]
1255
+ if (!value || value === META_FIELD_DEFAULTS[field]) continue
1256
+ if (!allowed.includes(value)) continue
1257
+ parts.push(t(`meta.${field}.${value}`))
1258
+ }
1096
1259
  return parts.join(' · ')
1097
1260
  }
1098
1261
 
@@ -1197,6 +1360,32 @@ window.__ModuleLoader__.load({
1197
1360
  ...emojiOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1198
1361
  )
1199
1362
  ),
1363
+ e('div', { className: 'soul-field' },
1364
+ e('label', { htmlFor: 'soul-tables' },
1365
+ t('field.tables'),
1366
+ e(SoulHint, { text: t('hint.tables') })
1367
+ ),
1368
+ e('select', {
1369
+ id: 'soul-tables',
1370
+ value: localTables,
1371
+ onChange: (ev) => setLocalTables(ev.target.value)
1372
+ },
1373
+ ...tablesOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1374
+ )
1375
+ ),
1376
+ e('div', { className: 'soul-field' },
1377
+ e('label', { htmlFor: 'soul-replyLength' },
1378
+ t('field.replyLength'),
1379
+ e(SoulHint, { text: t('hint.replyLength') })
1380
+ ),
1381
+ e('select', {
1382
+ id: 'soul-replyLength',
1383
+ value: localReplyLength,
1384
+ onChange: (ev) => setLocalReplyLength(ev.target.value)
1385
+ },
1386
+ ...replyLengthOptions.map(opt => e('option', { key: opt.value, value: opt.value }, opt.label))
1387
+ )
1388
+ ),
1200
1389
  e('div', { className: 'soul-field' },
1201
1390
  e('label', { htmlFor: 'soul-language' },
1202
1391
  t('field.language'),
@@ -1239,12 +1428,16 @@ window.__ModuleLoader__.load({
1239
1428
  : e(Fragment, null,
1240
1429
  ...personaNames.map((name) => {
1241
1430
  const entry = personas[name] || {}
1431
+ // 内置预设不提供删除(后端也会拒绝),在 actions 区原删除按钮的
1432
+ // 位置显示「内置」标记,行宽因此不会跳动。
1433
+ const builtin = !!entry.builtin
1242
1434
  return e('div', { className: 'soul-persona-row', key: name },
1243
1435
  e('span', { className: 'soul-persona-name' }, name === activePersona ? `★ ${name}` : name),
1244
1436
  e('span', { className: 'soul-persona-meta' }, personaRowMeta(entry)),
1245
1437
  e('span', { className: 'soul-persona-actions' },
1438
+ builtin && e('span', { className: 'soul-persona-badge' }, t('personas.builtin')),
1246
1439
  e('button', { type: 'button', className: 'soul-prompt-link', onClick: () => handleUsePersona(name) }, t('personas.use')),
1247
- e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
1440
+ !builtin && e('button', { type: 'button', className: 'soul-prompt-link soul-persona-danger', onClick: () => handleDeletePersona(name) }, t('personas.delete'))
1248
1441
  )
1249
1442
  )
1250
1443
  })
@@ -1262,7 +1455,8 @@ window.__ModuleLoader__.load({
1262
1455
  onClick: handleSavePersona,
1263
1456
  disabled: saving || !personaName.trim()
1264
1457
  }, t('personas.save'))
1265
- )
1458
+ ),
1459
+ e('div', { className: 'soul-status' }, t('personas.hint'))
1266
1460
  ),
1267
1461
 
1268
1462
  e(SoulAccordion, {
@@ -1363,7 +1557,7 @@ window.__ModuleLoader__.load({
1363
1557
 
1364
1558
  e(SoulAccordion, {
1365
1559
  id: 'soul-prompt',
1366
- title: t('prompt.title'),
1560
+ title: t(promptIsDraft ? 'prompt.titleDraft' : 'prompt.title'),
1367
1561
  summary: promptSummary,
1368
1562
  open: showPrompt,
1369
1563
  onToggle: togglePrompt
@@ -1374,7 +1568,9 @@ window.__ModuleLoader__.load({
1374
1568
  ? e('div', { className: 'soul-error' }, promptError)
1375
1569
  : e(Fragment, null,
1376
1570
  e('pre', { className: 'soul-prompt-pre' }, promptText || t('prompt.empty')),
1377
- e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) }))
1571
+ e('div', { className: 'soul-status' }, t('prompt.chars', { n: String(promptText.length) })),
1572
+ promptInvalid.length > 0 && e('div', { className: 'soul-error' },
1573
+ t('prompt.invalid', { fields: promptInvalid.join('、') }))
1378
1574
  ))
1379
1575
  ),
1380
1576
 
@@ -1528,11 +1724,79 @@ window.__ModuleLoader__.load({
1528
1724
  // 导航 label / 图标替换用的翻译函数(bind 返回的函数在调用时读取当前语言)
1529
1725
  const navT = ctx.locale.bind(NS)
1530
1726
 
1531
- // DSH 0.1.x 不支持 settings.section 的 icon 字段
1532
- // 使用 DOM 操作动态替换图标(类似 dsh-better-sidebar 的方案)
1727
+ // DSH 的设置导航图标是壳层硬编码的 id → 图标映射(见 ui-settings-general 的
1728
+ // navIcon():account/models/agent-presets/plugins/archived-sessions,其余一律
1729
+ // 回退成设置齿轮),插件无法通过槽位声明自己的图标(settings.section 的选项
1730
+ // 只有 id / order / label)。因此改用 DOM 替换,把齿轮换成与插件图标同一套
1731
+ // 几何的「灵魂火花 + 光轨」(类似 dsh-better-sidebar 的方案)。
1533
1732
  const SOUL_NAV_MARKER = 'data-soul-settings-nav'
1534
1733
  const SOUL_ICON_MARKER = 'data-soul-icon-replaced'
1535
1734
 
1735
+ // 与 assets/icon.svg(插件图标)同源几何:同一条光轨与火花路径。
1736
+ // 客户端读不到文件系统,只能内联一份;verify-nav-icon.mjs 有断言核对二者的
1737
+ // 路径数据逐字一致,防止漂移。
1738
+ //
1739
+ // 与插件图标有两处**有意差异**,都是为了与设置导航里的兄弟栏目看齐:
1740
+ // 1) 颜色——插件图标是彩色渐变,而导航栏那一排图标全是单色线描(壳层
1741
+ // navIcon() 生成的宿主机图标用 stroke="currentColor")。这里同样用
1742
+ // currentColor:既与邻居统一,又能自动跟随主题、选中态与禁用态,
1743
+ // 不必自己判断暗色模式。
1744
+ // 2) 显示窗口——icon.svg 用 `0 0 36 36`(图形含描边占 26.8 单位,留白适合
1745
+ // 列表里的大图标)。导航栏旁的宿主图标实测几乎满格——16px 下墨迹跨度
1746
+ // 14.25px(占 89%),所以沿用 36 网格会让这个小图标显得又小又轻。这里把
1747
+ // 窗口收紧到「同一个中心 (18,18)、边长 28.4」,让墨迹跨度落到 ~14px。
1748
+ // 数值是量出来的:预览探针把两种图标渲染到 canvas 逐像素统计。
1749
+ const NAV_ICON_VIEW_BOX = '3.8 3.8 28.4 28.4'
1750
+ const NAV_ICON_TRAIL_D = 'M18 6A12 12 0 1 1 6.72 22.1'
1751
+ const NAV_ICON_SPARK_D = 'M18 11.6C18.52 15.15 20.85 17.48 24.4 18C20.85 18.52 18.52 20.85 18 24.4C17.48 20.85 15.15 18.52 11.6 18C15.15 17.48 17.48 15.15 18 11.6Z'
1752
+ // 线宽按**显示后的像素**与邻居对齐:宿主图标是 16 网格 + 1.3 线宽,而我们
1753
+ // 的窗口是 28.4 单位宽、显示成 16px(缩放 16 ÷ 28.4 ≈ 0.5634),所以取
1754
+ // 1.3 ÷ 0.5634 ≈ 2.3。照搬 icon.svg 的 2.8 会让弧线比邻居粗一圈。
1755
+ const NAV_ICON_STROKE_WIDTH = '2.3'
1756
+ // 宿主按 size=16 渲染,宽高是 svg 的属性(不是 CSS)。万一将来改由 CSS 控制尺寸,
1757
+ // 我们也补一个 16——否则 svg 会退回 300×150 的默认尺寸,把导航栏撑坏。
1758
+ const NAV_ICON_FALLBACK_SIZE = '16'
1759
+ const SVG_NS = 'http://www.w3.org/2000/svg'
1760
+
1761
+ /** 建一个 SVG 元素并铺上属性(避免 innerHTML,无需解析)。 */
1762
+ const svgNode = (name, attributes) => {
1763
+ const node = document.createElementNS(SVG_NS, name)
1764
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value)
1765
+ return node
1766
+ }
1767
+
1768
+ /**
1769
+ * 把导航按钮里宿主生成的齿轮图标改画成插件图标(单色版)。
1770
+ *
1771
+ * 只替换 <svg> 的内容与 viewBox,保留节点本身——宿主的 className
1772
+ * (…navIcon,flex:none)、width/height(size=16)与 aria-hidden 因此
1773
+ * 原样生效,不必自己复刻一套尺寸规则;只有宿主将来不给宽高时才补默认值。
1774
+ *
1775
+ * 宿主 svg 上挂着 fill="none" 与 stroke-width(medium 为 1.3),这两条会
1776
+ * 向下继承,所以每条 path 都写全自身样式:光轨只要描边,火花只要填充。
1777
+ * 颜色一律 currentColor,取到的是按钮文字色——与旁边几个图标同一个值。
1778
+ */
1779
+ const paintSoulNavIcon = (svg) => {
1780
+ svg.setAttribute('viewBox', NAV_ICON_VIEW_BOX)
1781
+ if (!svg.getAttribute('width')) svg.setAttribute('width', NAV_ICON_FALLBACK_SIZE)
1782
+ if (!svg.getAttribute('height')) svg.setAttribute('height', NAV_ICON_FALLBACK_SIZE)
1783
+
1784
+ const trail = svgNode('path', {
1785
+ d: NAV_ICON_TRAIL_D,
1786
+ fill: 'none',
1787
+ stroke: 'currentColor',
1788
+ 'stroke-width': NAV_ICON_STROKE_WIDTH,
1789
+ 'stroke-linecap': 'round'
1790
+ })
1791
+ const spark = svgNode('path', {
1792
+ d: NAV_ICON_SPARK_D,
1793
+ fill: 'currentColor',
1794
+ stroke: 'none'
1795
+ })
1796
+
1797
+ svg.replaceChildren(trail, spark)
1798
+ }
1799
+
1536
1800
  const registerSettingsNavIcon = () => {
1537
1801
  let disposed = false
1538
1802
 
@@ -1548,28 +1812,12 @@ window.__ModuleLoader__.load({
1548
1812
  // 检查是否已经替换过图标
1549
1813
  if (button.hasAttribute(SOUL_ICON_MARKER)) continue
1550
1814
 
1551
- // 查找并替换图标
1815
+ // 把宿主给未知 id 用的设置齿轮,改画成插件自身的图标
1552
1816
  const existingIcon = button.querySelector('svg')
1553
1817
  if (existingIcon) {
1554
- // 标记已替换
1818
+ // 标记已替换;React 重建按钮时标记随之消失,会重新画一次
1555
1819
  button.setAttribute(SOUL_ICON_MARKER, 'true')
1556
-
1557
- // 创建星形图标(跟随主题色)
1558
- const starSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1559
- starSvg.setAttribute('viewBox', '0 0 24 24')
1560
- starSvg.setAttribute('fill', 'none')
1561
- starSvg.setAttribute('stroke', 'currentColor')
1562
- starSvg.setAttribute('stroke-width', '2')
1563
- starSvg.setAttribute('stroke-linecap', 'round')
1564
- starSvg.setAttribute('stroke-linejoin', 'round')
1565
- starSvg.style.width = '16px'
1566
- starSvg.style.height = '16px'
1567
-
1568
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1569
- path.setAttribute('d', 'M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z')
1570
- starSvg.appendChild(path)
1571
-
1572
- existingIcon.replaceWith(starSvg)
1820
+ paintSoulNavIcon(existingIcon)
1573
1821
  }
1574
1822
  } else {
1575
1823
  button.removeAttribute(SOUL_NAV_MARKER)