dsh-soul 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  import { join } from 'node:path'
20
20
  import { homedir } from 'node:os'
21
21
  import { createRequire } from 'node:module'
22
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
23
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
23
24
  import {
24
25
  DEFAULT_CONFIG,
@@ -33,7 +34,7 @@ import {
33
34
  sanitizePersonaName
34
35
  } from './lib/config.mjs'
35
36
  import { createInjectionSource } from './lib/injection.mjs'
36
- import { isBuiltinPersona, mergePersonas, personaMatches, resolvePersona, declaredPersonaKeys } from './lib/personas.mjs'
37
+ import { isBuiltinPersona, builtinPersonaNames, mergePersonas, personaMatches, resolvePersona, declaredPersonaKeys } from './lib/personas.mjs'
37
38
  import { configWriteRefusal, describeConfigFailure, moveAsideConfigFile, readConfigFile, writeConfigFile } from './lib/store.mjs'
38
39
 
39
40
  const name = 'soul'
@@ -56,6 +57,100 @@ function pluginVersion() {
56
57
  }
57
58
  }
58
59
 
60
+ // ==================== 人设插画(方案 C:卡片左侧方图) ====================
61
+ //
62
+ // 图放 assets/personas/<内置人设名>.webp,随包发布;客户端按人设名请求
63
+ // `/api/soul/persona-art/<name>`,宿主读文件返回。三条设计取舍:
64
+ //
65
+ // ① **不进客户端包**:客户端模块由宿主注入,把 7 张图内联成 data URL 会让
66
+ // 客户端包从 114KB 涨到 1MB+;走路由则按需加载、可缓存。
67
+ // ② **缺图回退占位而不是报错**:只有部分人设配了图时,其余显示官方鲸鱼轮廓的
68
+ // 占位图。用户看到的是「一只鲸鱼」而不是破图,且新增人设不必同时补图。
69
+ // ③ **只服务内置人设名**:文件名由人设名构成,必须经白名单校验 —— 否则
70
+ // `/api/soul/persona-art/../../package.json` 这类路径穿越能读到包内任意文件。
71
+
72
+ const PERSONA_ART_DIR = 'assets/personas'
73
+ // 允许的图片扩展名(宿主对插件资源的白名单:SVG / PNG / JPEG / WebP)
74
+ const ART_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.svg']
75
+ // MIME 表:必须显式给出,否则浏览器可能按 octet-stream 下载而不是渲染
76
+ const ART_MIME = {
77
+ '.webp': 'image/webp',
78
+ '.png': 'image/png',
79
+ '.jpg': 'image/jpeg',
80
+ '.jpeg': 'image/jpeg',
81
+ '.svg': 'image/svg+xml'
82
+ }
83
+ const PLACEHOLDER_FILE = 'placeholder.svg'
84
+ // 自定义(自建)预设的默认贴图:鲸鱼剪影 + 装饰元素,用户提供的品牌插画。
85
+ // 与内置插画同为 512×512 WebP、视觉重量一致,但形态是无五官的剪影 ——
86
+ // 内置是有具体人格的具象插画,自建是「还没定制」的中性底图,一眼可区分。
87
+ const CUSTOM_ART_FILE = 'custom-placeholder.webp'
88
+
89
+ // 包根目录下的 assets/personas 绝对路径
90
+ function personaArtDir() {
91
+ try {
92
+ const pkgDir = createRequire(import.meta.url).resolve('./package.json').replace(/[\\/]package\.json$/, '')
93
+ return join(pkgDir, PERSONA_ART_DIR)
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ // 解析某个内置人设的插画(返回文件名,或回退到占位图;都不可用返回 null)
100
+ //
101
+ // 白名单校验用 isBuiltinPersona:人设名因此只能是既定 7 个之一,不含 / 与 ..,
102
+ // 路径穿越从结构上不可能发生(而不是靠字符串替换去猜)。
103
+ function resolvePersonaArt(name) {
104
+ const dir = personaArtDir()
105
+ if (!dir || typeof name !== 'string') return null
106
+ // 自建预设没有配套插画 ⇒ 用专用默认贴图(本版只做样式调整;
107
+ // 允许用户上传本地图片作为插画是后续版本的功能)。
108
+ //
109
+ // 这里**仍不接受任意名字拼路径**:只有内置名会走下面的按名查找,
110
+ // 非内置名一律返回固定文件,路径穿越依然不可能发生。
111
+ if (!isBuiltinPersona(name)) {
112
+ const custom = join(dir, CUSTOM_ART_FILE)
113
+ // MIME 按扩展名取,不写死 —— 贴图格式变过一次(svg → webp),
114
+ // 写死会让浏览器把它当 octet-stream 下载而不是渲染。
115
+ const ext = CUSTOM_ART_FILE.slice(CUSTOM_ART_FILE.lastIndexOf('.'))
116
+ return existsSync(custom) ? { file: custom, mime: ART_MIME[ext] } : null
117
+ }
118
+ for (const ext of ART_EXTENSIONS) {
119
+ const file = join(dir, `${name}${ext}`)
120
+ if (existsSync(file)) return { file, mime: ART_MIME[ext] }
121
+ }
122
+ const fallback = join(dir, PLACEHOLDER_FILE)
123
+ if (existsSync(fallback)) return { file: fallback, mime: ART_MIME['.svg'] }
124
+ return null
125
+ }
126
+
127
+ // 已配图的人设名列表(诊断端点用:一眼看出哪几个还缺图)
128
+ function personasWithArt() {
129
+ const dir = personaArtDir()
130
+ if (!dir || !existsSync(dir)) return []
131
+ let files = []
132
+ try {
133
+ files = readdirSync(dir)
134
+ } catch {
135
+ return []
136
+ }
137
+ const out = []
138
+ for (const name of builtinPersonaNamesSafe()) {
139
+ if (ART_EXTENSIONS.some((ext) => files.includes(`${name}${ext}`))) out.push(name)
140
+ }
141
+ return out
142
+ }
143
+
144
+ // builtinPersonaNames 的容错包装:诊断路径不应因为导入异常而失败
145
+ function builtinPersonaNamesSafe() {
146
+ try {
147
+ return builtinPersonaNames()
148
+ } catch {
149
+ return []
150
+ }
151
+ }
152
+
153
+
59
154
  // ==================== 提示词文案与构建器 ====================
60
155
 
61
156
  // 提示词固定文案表:所有进入 system prompt / 会话注入 / 工具返回的文本,
@@ -114,6 +209,8 @@ const PROMPT_TEXT = {
114
209
  toolNoChanges: '没有要更改的设置',
115
210
  toolValidationFailed: '配置校验失败:',
116
211
  toolProposal: '已收到人设变更请求(确认模式已开启,尚未生效):',
212
+ toolRejected: '你在审批面板中拒绝了这次人设变更,未做任何修改。',
213
+ toolApprovalDisabled: '说明:本次会话的审批通道已被禁用(approval policy = never),因此没有弹出确认面板;这不代表你做了拒绝操作。',
117
214
  toolProposalHint: '在用户确认前不会生效;用户可用 /soul confirm 确认,或 /soul reject 拒绝。'
118
215
  },
119
216
  en: {
@@ -169,6 +266,8 @@ const PROMPT_TEXT = {
169
266
  toolNoChanges: 'No settings to change',
170
267
  toolValidationFailed: 'Config validation failed: ',
171
268
  toolProposal: 'Persona change requested (confirmation mode is on — not applied yet):',
269
+ toolRejected: 'You rejected this persona change in the approval panel — nothing was modified.',
270
+ toolApprovalDisabled: 'Note: the approval channel is disabled in this session (approval policy = never), so no confirmation panel appeared; this does not mean you rejected anything.',
172
271
  toolProposalHint: 'It stays pending until the user confirms with /soul confirm or rejects with /soul reject.'
173
272
  }
174
273
  }
@@ -688,6 +787,15 @@ function registerRoutes(ctx) {
688
787
  res.end(JSON.stringify(body))
689
788
  }
690
789
 
790
+ // 方法校验:这是**破坏性写**入口(重置配置、必要时丢弃并备份损坏文件),
791
+ // 缺了它任何 HTTP 方法都能触发 —— 而 GET 是浏览器预取、爬虫、<img src>、
792
+ // 链接预览都会发出的方法,一个 GET 就能重置用户配置。
793
+ // 同期的 personas/save、personas/delete 都有此校验,此处属遗漏。
794
+ if (req.method !== 'POST') {
795
+ send(405, { ok: false, error: 'Method not allowed' })
796
+ return
797
+ }
798
+
691
799
  try {
692
800
  // 经由逃生口:配置损坏时这是设置页唯一还能成功的写操作。
693
801
  // backupPath 非 null 说明刚才确实丢弃了一份损坏的配置(已备份),
@@ -979,6 +1087,45 @@ function registerPersonaRoutes(ctx) {
979
1087
  }
980
1088
  }
981
1089
  })
1090
+
1091
+ // 人设插画:/api/soul/persona-art/<内置人设名>
1092
+ //
1093
+ // 用 prefix 路由(WebRouteKind 支持 'exact' | 'prefix')而不是 query 参数:
1094
+ // 路径里直接带人设名,浏览器可对每个 URL 独立缓存与复用连接。
1095
+ // 名字必须命中内置人设白名单(isBuiltinPersona),路径穿越因此不可能发生。
1096
+ wsCtx.webServer.register({
1097
+ kind: 'prefix',
1098
+ path: '/api/soul/persona-art',
1099
+ handler: async (req, res) => {
1100
+ try {
1101
+ if (req.method !== 'GET') {
1102
+ res.writeHead(405, { 'content-type': 'application/json' })
1103
+ res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }))
1104
+ return
1105
+ }
1106
+ const raw = String(req.url || '').split('?')[0]
1107
+ const name = decodeURIComponent(raw.slice('/api/soul/persona-art'.length).replace(/^\/+/, ''))
1108
+ const art = resolvePersonaArt(name)
1109
+ if (!art) {
1110
+ // 图与占位图都没有(理论上只会在 assets 被外部删空时出现)
1111
+ res.writeHead(404, { 'content-type': 'application/json' })
1112
+ res.end(JSON.stringify({ ok: false, error: `没有可用的插画:${name || '(空)'}` }))
1113
+ return
1114
+ }
1115
+ const body = readFileSync(art.file)
1116
+ res.writeHead(200, {
1117
+ 'content-type': art.mime,
1118
+ 'content-length': String(body.length),
1119
+ // 图随包发布、路径即内容标识 ⇒ 可长期缓存;换图必然伴随版本号变化
1120
+ 'cache-control': 'public, max-age=86400'
1121
+ })
1122
+ res.end(body)
1123
+ } catch (err) {
1124
+ res.writeHead(500, { 'content-type': 'application/json' })
1125
+ res.end(JSON.stringify({ ok: false, error: String(err.message || err) }))
1126
+ }
1127
+ }
1128
+ })
982
1129
  })
983
1130
  }
984
1131
 
@@ -1496,6 +1643,117 @@ function registerSystemPrompt(ctx) {
1496
1643
  })
1497
1644
  }
1498
1645
 
1646
+ // ==================== 人设变更的宿主审批 ====================
1647
+ //
1648
+ // 确认模式开启时,人设变更走**宿主的原生审批交互**(输入框处的
1649
+ // 「允许一次 / 拒绝」面板),而不是让用户手敲 /soul confirm。
1650
+ //
1651
+ // 为什么复用宿主审批而不是自绘弹框:
1652
+ // · 面板、键盘(Enter 允许 / Esc 拒绝)、读屏、一次性授权语义都由宿主实现;
1653
+ // · ask / decided 会写入会话审计日志(谁在何时批准了什么人设变更);
1654
+ // · 多个插件同时要审批时由 waterfall 自然排队,不会出现两个弹框抢焦点。
1655
+ //
1656
+ // 返回三态,调用方据此决定继续落盘 / 拒绝 / 退化:
1657
+ // 'allowed' —— 用户批准,调用方继续写盘
1658
+ // 'rejected' —— 用户拒绝,调用方返回失败
1659
+ // 'unavailable'—— 拿不到审批能力(无 agent、宿主未装审批服务、无 Web UI 应答者),
1660
+ // 调用方退化到 /soul confirm 文本流程
1661
+ //
1662
+ // 刻意**不加超时**:用户不点就一直等,与宿主其它审批一致。
1663
+ // 加超时会产生「只是没来得及点就被自动拒绝」这种难以解释的行为。
1664
+ function approvalOutcomeOf(outcome) {
1665
+ if (outcome === 'allowed-once') return 'allowed'
1666
+ if (outcome === 'rejected') return 'rejected'
1667
+ // 'cancelled'(请求被撤回)与 'unavailable'(无应答者)都归为「审批不可用」,
1668
+ // 交由文本流程兜底 —— 用户仍有机会通过 /soul confirm 应用这次变更。
1669
+ return 'unavailable'
1670
+ }
1671
+
1672
+ // 读取当前生效的审批策略。复刻宿主 ApprovalService.effectivePolicy 的算法
1673
+ // (overrideOf(session) ?? config.policy ?? 'ask')。
1674
+ //
1675
+ // 任一项不可读时保守回落到 'ask' —— 回落到 ask 意味着「仍去问用户」,
1676
+ // 失败方向是「多问一次」而不是「静默改配置」。
1677
+ function resolveApprovalPolicy(approval, agent) {
1678
+ try {
1679
+ const session = agent && agent.session
1680
+ if (typeof approval.overrideOf === 'function' && session) {
1681
+ const override = approval.overrideOf(session)
1682
+ if (override === 'never' || override === 'ask') return override
1683
+ }
1684
+ const configured = approval.config && approval.config.policy
1685
+ if (configured === 'never' || configured === 'ask') return configured
1686
+ } catch {
1687
+ // 读策略失败不应影响主流程 —— 继续按 ask 走(让宿主的真实判定说话)
1688
+ }
1689
+ return 'ask'
1690
+ }
1691
+
1692
+ async function requestToolApproval(ctx, exec, changes) {
1693
+ try {
1694
+ // agent 是必须的:宿主客户端表单靠它解析会话归属,拿不到就会放弃应答
1695
+ //(dsh-client-ui-approval 里 scopeOf(owner) === undefined 时直接 next())。
1696
+ const agent = exec && exec.agent
1697
+ if (!agent) return 'unavailable'
1698
+ // 服务解析:以**注册工具的 ctx**(toolsCtx)为主 —— 这与 dsh-tools 自己的
1699
+ // serviceAsk 写法一致(它在工具执行中用 this.ctx.get('approval'))。
1700
+ //
1701
+ // 实测教训:最初传的是 registerTools 的根 ctx,ctx.get('approval') 返回
1702
+ // undefined ⇒ 每次都退化成 /soul confirm 文本流程,看起来像「功能没生效」。
1703
+ // 另外 cordis 的 agent 作用域可能更精确(审批本就该路由到该 agent 的会话),
1704
+ // 故一并作为候选,任一命中即用。
1705
+ const candidates = []
1706
+ if (agent && typeof agent.ctx === 'object' && agent.ctx && typeof agent.ctx.get === 'function') candidates.push(agent.ctx)
1707
+ if (ctx && typeof ctx.get === 'function') candidates.push(ctx)
1708
+ let approval
1709
+ for (const c of candidates) {
1710
+ const found = c.get('approval')
1711
+ if (found && typeof found.request === 'function') { approval = found; break }
1712
+ }
1713
+ if (!approval || typeof approval.request !== 'function') return 'unavailable'
1714
+
1715
+ // —— 策略预判:避免把「通道被禁用」误报成「用户拒绝」——
1716
+ //
1717
+ // 宿主在 effectivePolicy(session) === 'never' 时**不问任何人**,直接返回
1718
+ // 'rejected'(见 dsh-user-approval 的 decide())。而 'rejected' 这个取值把
1719
+ // 两件完全不同的事混在一起:
1720
+ // ① 用户真的点了「拒绝」
1721
+ // ② 审批通道被策略禁用,无人被询问
1722
+ // 若不区分,就会向用户报告一件**从未发生的事**(实测踩过:策略为 never 时
1723
+ // 提示「你在审批面板中拒绝了」,而用户根本没看到面板)。
1724
+ //
1725
+ // 故在发起请求前复刻宿主的策略算法,判定为 'never' 时直接走文本确认
1726
+ // (与 'unavailable' 同一落点),并给出准确文案。
1727
+ //
1728
+ // 为什么不直接应用变更(把 never 当作「无需确认」):requireToolConfirmation
1729
+ // 是用户为本功能单独设的偏好,而 never 是会话级的审批通道策略。用后者悄悄推翻
1730
+ // 前者,等于让一个通道开关替用户取消了他自己的功能选择。真要免确认,正确做法是
1731
+ // 关掉设置页里的「人设变更需确认」开关 —— 那才是为这件事准备的开关。
1732
+ if (resolveApprovalPolicy(approval, agent) === 'never') return 'policy-disabled'
1733
+
1734
+ const detail = Object.entries(changes)
1735
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
1736
+ .join(', ')
1737
+ const outcome = await approval.request({
1738
+ agent,
1739
+ toolName: 'set_persona',
1740
+ ...(exec && exec.callId !== undefined ? { callId: exec.callId } : {}),
1741
+ ...(exec && exec.signal !== undefined ? { signal: exec.signal } : {}),
1742
+ reason: '人设变更需要你的确认',
1743
+ // 中英并列:宿主按当前 UI 语言选取,审计日志里仍保留 reason 原文
1744
+ displayReason: {
1745
+ en: 'Persona change requires your confirmation',
1746
+ zh: '人设变更需要你的确认',
1747
+ 'zh-Hans': '人设变更需要你的确认'
1748
+ }
1749
+ })
1750
+ return approvalOutcomeOf(outcome)
1751
+ } catch {
1752
+ // 审批链路任何异常都不应让人设变更彻底失败 —— 退化为文本确认
1753
+ return 'unavailable'
1754
+ }
1755
+ }
1756
+
1499
1757
  // ==================== Agent 可调用工具 ====================
1500
1758
 
1501
1759
  // tools 是可选 host 服务且 ABI 版本敏感(同 dsh-share-page 的守卫)。
@@ -1591,8 +1849,23 @@ function registerTools(ctx) {
1591
1849
  },
1592
1850
  },
1593
1851
  },
1852
+ // 结果渲染:宿主每次工具**成功执行**后都会无条件调用它
1853
+ // (dsh-tools 的 createSuccessResult → tool.output.render(...))。
1854
+ // 不提供会怎样:defineTool 无条件下包一份 `render(args, value) { return userRender(...) }`,
1855
+ // 而 userRender 是 undefined ⇒ 注册能过(底层 register 校验的是包装后的函数),
1856
+ // 但**每次成功调用都在渲染阶段抛 TypeError: userRender is not a function**,
1857
+ // 宿主把它包成 projectionError("render"),用户视角是「Agent 说改了却报错」。
1858
+ // 自带插件 19 个都提供它。契约:返回 [{ type: 'text', text }](无损 JSON)。
1859
+ render: (_args, value) => {
1860
+ // 直接把 message 作为给模型读的文本:它已经是完整的一句话(含 pending 提议与
1861
+ // 失败原因),再套一层 JSON.stringify 只会让模型多解析一层转义。
1862
+ const text = typeof value?.message === 'string' && value.message.length > 0
1863
+ ? value.message
1864
+ : (value?.ok === true ? 'ok' : 'failed')
1865
+ return [{ type: 'text', text }]
1866
+ },
1594
1867
  },
1595
- async execute(args) {
1868
+ async execute(args, exec) {
1596
1869
  try {
1597
1870
  // 与 HTTP 保存共用同一套校验(白名单 / 类型 / 长度 / 枚举)
1598
1871
  const { patch, errors } = sanitizeConfig(args)
@@ -1605,7 +1878,22 @@ function registerTools(ctx) {
1605
1878
  return { ok: true, message: T0.toolNoChanges }
1606
1879
  }
1607
1880
 
1608
- // 确认模式:不落盘,返回待确认提议(/soul confirm 应用、/soul reject 拒绝)
1881
+ // 确认模式:先走**宿主的原生审批交互**(输入框处的「允许一次 / 拒绝」面板)。
1882
+ //
1883
+ // 三态处理:
1884
+ // allowed → 用户已批准,继续往下走写盘(故这里不 return)
1885
+ // rejected → 用户拒绝,返回失败且不落盘
1886
+ // 三态 + 一个策略态:
1887
+ // allowed → 用户已批准,继续往下走写盘(故这里不 return)
1888
+ // rejected → 用户明确拒绝,返回失败且不落盘
1889
+ // unavailable → 拿不到审批能力(无 agent / 宿主未装审批服务 /
1890
+ // 客户端没有应答者),退化到 /soul confirm 文本流程
1891
+ // policy-disabled → 审批通道被会话策略禁用(never)⇒ 同样走文本流程,
1892
+ // 但文案必须说明「通道被禁用」,不能谎称用户拒绝
1893
+ //
1894
+ // 退化路径必须保留:纯 TUI 或无 Web UI 的场景没有应答者,审批会返回
1895
+ // 'unavailable',此时文本流程是唯一可用的确认途径。删掉它会让这些场景
1896
+ // 彻底无法确认人设变更。
1609
1897
  const snapshot = await loadConfig()
1610
1898
  if (snapshot.requireToolConfirmation) {
1611
1899
  const T0 = promptTextOf(snapshot)
@@ -1614,14 +1902,24 @@ function registerTools(ctx) {
1614
1902
  return { ok: true, message: T0.toolNoChanges }
1615
1903
  }
1616
1904
  const proposalChanges = Object.fromEntries(pendingKeys.map((key) => [key, patch[key]]))
1617
- pendingPersonaProposal = { patch: proposalChanges, proposedAt: new Date().toISOString() }
1618
- const detail = pendingKeys.map((key) => `${key}=${JSON.stringify(patch[key])}`).join(', ')
1619
- return {
1620
- ok: true,
1621
- pending: true,
1622
- changes: proposalChanges,
1623
- message: `${T0.toolProposal}\n${detail}\n${T0.toolProposalHint}`
1905
+ const verdict = await requestToolApproval(toolsCtx, exec, proposalChanges)
1906
+ if (verdict === 'rejected') {
1907
+ return { ok: false, message: T0.toolRejected }
1908
+ }
1909
+ if (verdict === 'unavailable' || verdict === 'policy-disabled') {
1910
+ pendingPersonaProposal = { patch: proposalChanges, proposedAt: new Date().toISOString() }
1911
+ const detail = pendingKeys.map((key) => `${key}=${JSON.stringify(patch[key])}`).join(', ')
1912
+ // 通道被禁用时补一句准确说明:否则用户会以为是面板出了问题,
1913
+ // 或误以为是自己拒绝了(此前的错误措辞正是这么来的)。
1914
+ const why = verdict === 'policy-disabled' ? `${T0.toolApprovalDisabled}\n` : ''
1915
+ return {
1916
+ ok: true,
1917
+ pending: true,
1918
+ changes: proposalChanges,
1919
+ message: `${T0.toolProposal}\n${detail}\n${why}${T0.toolProposalHint}`
1920
+ }
1624
1921
  }
1922
+ // verdict === 'allowed' ⇒ 落到下面的写盘路径
1625
1923
  }
1626
1924
 
1627
1925
  // 写队列内合并,避免与其他写入方(Web UI、/soul 命令)竞态;
package/lib/personas.mjs CHANGED
@@ -14,12 +14,25 @@
14
14
  // pickPersonaValues,两者都只遍历 PERSONA_FIELDS)。v0.7.1 之前自建预设是
15
15
  // 「完整配置快照」、会写回这三个字段,旧数据由 normalizePersonas 迁移剥离。
16
16
  //
17
- // 2) **只声明要覆盖的字段**。预设是「部分覆盖」语义:应用时只写入预设实际
18
- // 声明的键,未声明的保持用户当前值。所以内置预设只写它真正要改变的人格维度。
17
+ // 2) **声明全部 6 个人格维度**。每个内置预设都显式列出 style / headingLists /
18
+ // emoji / tables / replyLength / customInstructions,即使某项就是默认值。
19
+ //
20
+ // 理由:这份声明列表**同时驱动应用与匹配**,而两者的需求相反 ——
21
+ // 应用时「只写声明字段」⇒ 声明越少越不越权;
22
+ // 匹配时「只比声明字段」⇒ 声明越少越宽松。
23
+ // 早期为「不越权」只声明要改的维度,代价是匹配退化成宽松比较:改一个预设
24
+ // 未声明的特质后 ★ 仍挂在它身上,而且**同一动作结果随预设而变**(改 emoji
25
+ // 在「极简主义者」上丢 ★,在「苏格拉底式提问者」上不丢)—— 用户无法形成
26
+ // 稳定预期(实测确认)。统一为全量声明后语义单一可预期:
27
+ // **改任何特质 ⇒ 不再属于该预设**。
28
+ //
29
+ // 代价(刻意接受):应用预设会把 6 个人格维度整体复位到预设值,而不是
30
+ // 「只改一部分、其余保留我手动的」—— 这与「用预设就是不想逐项自己配」
31
+ // 的心智模型一致。
19
32
  //
20
33
  // 3) **内置预设一律不声明 `language`**。输出语言在预设范围内(自建预设会保存
21
34
  // 与还原它),但内置预设是给任何语言用户共用的通用人格:写死 zh 会让英文用户
22
- // 应用一次就被强行切回中文。省略即「不动它」。
35
+ // 应用一次就被强行切回中文。省略即「不动它」——这是全量声明唯一的例外。
23
36
  //
24
37
  // 4) **内置预设不进磁盘**。它们只存在于本文件,磁盘上的 `config.personas`
25
38
  // 仅存用户自己保存的预设。好处有三:升级时内置内容自动更新(不会被用户
@@ -45,6 +58,9 @@ import { PERSONA_FIELDS, PERSONA_META_KEYS } from './config.mjs'
45
58
 
46
59
  export const BUILTIN_PERSONAS = {
47
60
  '苏格拉底式提问者': {
61
+ emoji: 'default',
62
+ tables: 'default',
63
+ replyLength: 'normal',
48
64
  style: 'professional',
49
65
  headingLists: 'less',
50
66
  customInstructions:
@@ -65,6 +81,7 @@ export const BUILTIN_PERSONAS = {
65
81
  '若信息不足以回答,只问一个最关键的问题,不要罗列一串待确认项。'
66
82
  },
67
83
  '资深架构师': {
84
+ emoji: 'default',
68
85
  style: 'professional',
69
86
  headingLists: 'more',
70
87
  tables: 'more',
@@ -76,6 +93,8 @@ export const BUILTIN_PERSONAS = {
76
93
  '涉及取舍时给出明确推荐并说明理由,不要以「都可以,看需求」收尾。'
77
94
  },
78
95
  '教学型讲解者': {
96
+ emoji: 'default',
97
+ tables: 'default',
79
98
  style: 'casual',
80
99
  headingLists: 'more',
81
100
  replyLength: 'detailed',
@@ -98,6 +117,9 @@ export const BUILTIN_PERSONAS = {
98
117
  '没有发现问题时,明确说明检查过哪些方面,不要为凑数编造无关痛痒的意见。'
99
118
  },
100
119
  '头脑风暴伙伴': {
120
+ emoji: 'default',
121
+ tables: 'default',
122
+ replyLength: 'normal',
101
123
  style: 'casual',
102
124
  headingLists: 'more',
103
125
  customInstructions:
@@ -107,6 +129,8 @@ export const BUILTIN_PERSONAS = {
107
129
  '避免只给显而易见的选项,至少包含一个不常规但有可行性的思路。'
108
130
  },
109
131
  '自驱型协作者': {
132
+ tables: 'default',
133
+ replyLength: 'normal',
110
134
  style: 'professional',
111
135
  headingLists: 'more',
112
136
  emoji: 'more',
@@ -146,19 +170,20 @@ export function personaMatches(entry, config) {
146
170
  return keys.every((key) => entry[key] === config[key])
147
171
  }
148
172
 
149
- // 内置人设名(按定义顺序返回;UI 列表用它把内置项排在前面的展示区)
173
+ // 内置人设名(按定义顺序返回;UI 列表用它把内置项排在前面的展示区)。
174
+ // 「默认」排在最前 —— 它是出厂状态,列表里应当一眼可见。
150
175
  export function builtinPersonaNames() {
151
176
  return Object.keys(BUILTIN_PERSONAS)
152
177
  }
153
178
 
154
- // 是否为内置人设名(保存与删除路径的准入判据)
179
+ // 是否为内置人设名(保存与删除路径的准入判据)。默认与内置同样受保护。
155
180
  export function isBuiltinPersona(name) {
156
181
  return typeof name === 'string' && Object.prototype.hasOwnProperty.call(BUILTIN_PERSONAS, name)
157
182
  }
158
183
 
159
184
  // 合并用户预设库与内置预设,返回给前端的展示视图。
160
185
  // 内置条目额外带 `builtin: true` —— 前端据此渲染「内置」标记并隐藏删除按钮;
161
- // 用户预设不带该字段。内置优先,同名用户条目被遮蔽(见文件头约定 3)。
186
+ // 用户预设不带该字段。内置优先(默认在最前),同名用户条目被遮蔽(见文件头约定 3)。
162
187
  export function mergePersonas(userPersonas) {
163
188
  const out = {}
164
189
  for (const name of builtinPersonaNames()) {
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Soul",
4
+ "description": "Personalization for DSH: nickname, reply style and tone, traits, persona presets, and a composer light trail."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Soul 人设",
4
+ "description": "为 DSH 添加个性化设置:昵称、回复风格和语调、特质、人设预设,以及输入框光轨。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-soul",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "为 DeepSeek Harness 添加个性化设置。",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [
@@ -60,6 +60,7 @@
60
60
  },
61
61
  "./client": "./client/index.mjs",
62
62
  "./cordis.patch.yml": "./cordis.patch.yml",
63
+ "./locale/*.json": "./locale/*.json",
63
64
  "./package.json": "./package.json"
64
65
  },
65
66
  "files": [
@@ -67,6 +68,7 @@
67
68
  "lib",
68
69
  "client",
69
70
  "assets",
71
+ "locale",
70
72
  "cordis.patch.yml",
71
73
  "scripts",
72
74
  "README.md",