dsh-persona-cards —— 人设卡
在 设置 → 人设卡 里管理人设,在每个会话里选用哪一套。选中的那张会拼进
系统提示词,排在官方部署人设之后、所有工具说明之前。改完下一个模型步骤就生效,不用重启。
默认不注入任何人设 —— 要用哪张,得在会话里显式选。
另有一段全局设置:不挑会话、不挑人设,写了就永远注入(排在人设之前)。
见下面「全局设置」一节。
功能
- 设置里的独立一页:左侧设置导航新增「人设卡」,与「通用」「模型」「插件」并列。
- 全局设置(与人设卡并列的一块):一段对所有会话都成立的附加正文,
不管会话有没有选人设都会注入,且排在人设之前;留空即不注入。见下面专节。
- 多张卡:新建 / 编辑 / 删除,每张卡有名字、正文、启用开关。
- 卡片态 / 编辑态分离:列表默认是只读卡片(标题 + 正文预览 + 开关),
点「编辑」才展开输入框,点「完成」回到卡片态、点「取消」直接丢弃草稿。
- 会话级选择:会话输入框左侧有个「人设」选择器。选中只作用于当前会话,
别的会话不受影响;不选就是「不使用人设」。
- 自动保存:编辑完成时立即落盘;开关 / 删除 / 新建立即落盘;全局设置为 700ms 防抖落盘。
- 零污染:没选人设、全局设置也留空时,插件对那个会话的提示词完全不存在
(空段落会被 DSH 自动丢弃)。
全局设置:唯一一段"无条件的"注入
| 项 | 值 |
|---|
| 在哪写 | 设置 → 人设卡 → 页面顶部「全局设置」框 |
| 何时注入 | 永远 —— 不挑会话、不挑人设、不挑启用状态 |
| 注入位置 | 官方部署人设(0) 之后、选中的人设卡(100) 之前 → order = 90 |
| 段落名 | persona-cards:global |
| 存哪 | settings.json(与人设卡 cards.json 分开) |
| 空值语义 | 全空白 = 没写 = 整段被 DSH 丢弃,插件对这个会话不产生任何提示词 |
排序上刻意取 90:它是官方 SECTION_ORDERS 里 DEPLOYMENT_PERSONA_PREFIX(0) 与
本插件人设段 (100) 之间的空位,不会和任何官方段撞号。语义是「全局总纲 → 具体身份」:
对所有会话一致的要求先立,会话选的人设后置覆盖。
为什么单独一段,而不是拼进人设段:两段的生效条件完全不同(人设段"没选就空"、
全局段"写了就在")。合成一段就得在渲染时拼字符串,还要额外处理"只有全局没有人设"
的边界;分开之后 DSH 的 renderPrompt() 空段丢弃规则天然把两种情况都处理干净。
为什么单独一个文件:卡片与全局设置的自动保存是各自独立触发的,共用一个文件
就意味着每次落盘都写全量,一边的写入会把另一边刚改的内容按旧值覆盖回去。
分开之后两个文件各自「临时文件 + rename」,互不干扰。
{{model}} / {{cwd}} / {{provider}} 的引用规则与人设完全一致:落盘前过同一道
白名单闸门(normalizeBody()),其余引用一律拒绝保存并说明原因。
语义:设置页管仓库,会话管这次用哪张
| 层 | 存哪 | 作用 |
|---|
| 卡片 | cards.json | 设置页维护 |
| 启用开关 | 同上 | 只决定这张卡进不进备选池,不替任何会话做决定 |
| 会话 → 卡片 绑定 | session-cards.json | 会话界面的选择器写入 |
| 全局设置 | settings.json | 设置页维护,无条件注入,与会话/人设无关 |
| 注入什么 | —— | 全局设置(若有)+「绑了就用绑的那张;没绑就什么都不注入」 |
这个设计解决了一个实际问题:把几张卡全启用,新会话不会把几个人格叠加成
互相打架的身份指令 —— 因为「启用」不等于「生效」。
会话 id 来自段落 text 函数收到的组装上下文 —— @deepseek-ai/dsh-agent 的
assembleContextFor() 返回的是 { agent, scope: agent, signal? },
所以 context.agent.id 就是当前会话。拿不到 agent(或绑的卡被停用/清空/删除)时
一律按「不注入」处理,不会抛错、也不会误注入别的卡。
⚠️ 为什么选择器不在「新建会话」界面
试过了,DSH 的插槽体系里没有干净的位置:
- 新建会话的空状态(hero)里,所有会话级控件都被官方显式跳过 ——
dsh-client-ui-conversation 里写死了
input === undefined || sessionId === undefined ? null : renderSlot("conversation.input.left", {})。
新建会话时还没有 sessionId,所以这些槽一律不渲染。
- hero 区唯一能挂东西的是
conversation.hero.agentPreset,但它是 single 槽且已被官方占用
(replaceRisk: shadows-shipped-ui)—— 用它就得顶掉官方的 agent 预设选择器。
所以选择器放在 composer 工具行左侧(conversation.input.left,list 槽、空着、replaceRisk: none)。
效果是:发出第一条消息后选择器出现,之后随时可切;新会话的第一条消息用设置里的默认规则。
安装
包已链接进 desktop profile。手工装的三步(本机开发用):
- 包目录 junction 到
<profiles>/node_modules/dsh-persona-cards
<profile>/package.json 的 dependencies 加 "dsh-persona-cards": "0.2.0"
<profile>/cordis.patch.yml 追加 cordis.patch.yml 里的 insert 块
官方装法:dsh plugin --profile <p> add dsh-persona-cards
⚠️ 改完 Host 代码必须完全重启 DSH
DSH 的 Loader 复用 ESM 模块缓存:禁用/启用插件、改配置文件、新建会话都不会
重新 import lib/index.js。
两半的更新方式不一样,会错配:
| 半边 | 什么时候重新读盘 |
|---|
lib/client.js | dsh-client-modules 对这个文件挂了 watch —— 改完刷新页面就是新的 |
lib/index.js | 只在 DSH 进程启动时加载一次 |
所以「刷新页面」之后很容易出现界面是新的、Host 是旧的:新界面按新协议调
saveGlobal,旧 Host 的 switch 里没有这个 case,回一句
未知接口:saveGlobal,界面上就是「保存失败:未知接口:saveGlobal」。
现在这种错配会被显式认出来(快照里没有 global 字段 = Host 是旧版本),
设置页直接给出警告并禁用输入框,不会再让人对着"能打字却存不下去"猜。
修法只有一个:完全退出 DSH 再重新打开。
数据
| 项 | 位置 |
|---|
| 卡片文件 | $DSH_HOME/persona-cards/cards.json |
| 全局设置 | $DSH_HOME/persona-cards/settings.json |
| 会话绑定 | $DSH_HOME/persona-cards/session-cards.json |
| 诊断输出 | $DSH_HOME/persona-cards/diag.json(排查注册问题用,可删) |
$DSH_HOME 解析顺序:环境变量 DSH_HOME → ~/.dsh。
默认即 %USERPROFILE%\.dsh\persona-cards\cards.json。同目录下已有的 .md
人设文件不会被读写或修改。
注入位置
两个提示词段落,order 取自 @deepseek-ai/dsh-system-prompt 的 SECTION_ORDERS:
HARNESS_IDENTITY(-1000) → DEPLOYMENT_PERSONA_PREFIX(0) → ★persona-cards:global(90)
→ ★persona-cards:user(100) → PLAN_POLICY(500) → TEAM_POLICY(600) → 各工具说明(1000+)
| 段落名 | order | 内容 |
|---|
persona-cards:global | 90 | 全局设置,非空就注入,与上下文无关 |
persona-cards:user | 100 | 当前会话选中的那张人设卡,没选就是空串(整段被丢弃) |
DSH 0.2.0 兼容性(重要)
DSH 从 0.1.7-rc.2 升到 0.2.0-rc.1 后,cordis 收紧了 ctx 代理:
没声明 inject 的服务属性访问会直接抛错 ——
cannot get property "X" without inject(cordis/lib/index.js 的
ReflectService.handler.get)。
connection.rpc.handle() 内部是 register(owner = this.ctx, …) →
owner.webServer.register(route),而 this.ctx 解析成调用方 fiber 的 ctx
(官方注释明写:"channel registrations belong to the caller fiber")。
本插件刻意不声明 inject(声明会把 apply 推迟,见 whenService 注释),
于是官方通道整条挂掉,表现就是设置页报:
读取失败:transport failure for /persona-cards/state: HTTP 405
(405 = 请求落到了 fallback,说明路由压根没注册。)
修法:官方通道优先,失败自动降级。
降级路径用 ctx.get('webServer') —— 官方给的服务可选访问形式,不受 inject
约束 —— 自己挂 {kind:'prefix'} 路由,信任校验复用 connection.admit(req):
它是实例方法,只读 trustedHosts / browserAuth / operator 三个实例属性,
完全不碰 ctx。
已在真实运行环境实测过(建了个独立探针包,新包名没有 ESM 缓存所以当场加载):
| 检查项 | 结果 |
|---|
ctx.get('webServer') | object |
webServer.register | function |
ctx.get('connection') / .admit | object / function |
| 路由注册 | 成功 |
| 未认证请求打到该路由 | 401(不是 404/405),且确实进了处理器 |
401 是预期的:命令行的裸请求没有浏览器认证 cookie,被信任栅栏拦下;
页面内发起的同源请求会带 cookie,正常通过。
两个保命设计(别改)
-
{{...}} 闸门。DSH 的 renderPrompt() 会对每个段落做变量插值,而
interpolate() 在三种情况下直接抛异常:引用了没注册的名字
(unknown prompt variable)、引用了已注册但本次组装没有值的变量、以及写法
不合法(malformed prompt variable reference)。用户在人设里顺手写个
{{随手写的}} 就能把整个会话搞崩。
做法是开启插值 + 落盘前白名单校验:
- 开启插值,让 DSH 官方注册的变量真正替换成当前值;
- 保存时用
scanVariableReferences() 扫正文(顺序逐字复刻官方
interpolate(),包括「没有配对的单个 {{ 当普通文本放行」这条),
只放行 {{model}} / {{cwd}} / {{provider}},其余引用拒绝保存并说明原因。
全库扫过 DSH 的 289 个包:官方一共只注册了这三个变量(都在 dsh-agent-loop,
取值形如 context.agent?.options.model),而且官方自己没有任何内置段落
引用它们 —— 这三个就是留给插件/组合层用的。
-
不能用 deployment:persona-prefix 这个段名。它归
@deepseek-ai/dsh-persona 所有,而官方注释明写那个插件是 scope-only 的:
全局挂载会与注册表自身的注册相撞并 fail loud。所以本插件用自己的段名。
实现要点
dsh-persona-cards/
├── package.json # dsh.bundle.patch + dsh.client(平台 web)
├── cordis.patch.yml # 挂载声明
└── lib/
├── index.js # Host:持久化(卡片 / 绑定 / 全局设置)+ 两个提示词段落 + RPC 通道
└── client.js # Client:设置页 UI + 会话界面的人设选择器
Host ↔ Client 通信走 ctx.connection.rpc.handle(channel, handler)。
通道内部已经过 admit(req)(Host/Origin 信任栅栏 + 浏览器鉴权),被拒即
401/403 —— 所以本插件不需要自己写路由守卫,这也是没走
ctx.webServer.register() 野路子的原因。
客户端侧对应 ctx.connection.rpc.call(channel, endpoint, payload),
返回 {ok:true,value} / {ok:false,error},业务失败不抛异常,必须自己解包。
service 获取刻意不用依赖注入:apply 用 whenService() 轮询取
systemPrompt / connection。本机实测踩过一次 ctx.inject([...], cb)
静默挂起(fiber 显示 active,但回调始终没跑,RPC 通道就这么没了)。
dsh.client.inject 填的是包名,不是 cordis 服务名(package.json 里):
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
]
}
它是客户端插件之间的加载顺序依赖,让提供方先于消费方加载;cordis 服务依赖
写在 client.js 的 exports.inject 里(['slots','locale','connection'])。
这里一开始写成了服务名,虽然被宽容处理也能跑,但不合规范 —— 对齐参照物是官方
@deepseek-ai/dsh-client-ui-settings-general(同样注册独立设置页,用的正是这三个包)。
这个声明只在客户端启动时解析一次:改完它必须重启客户端,改 client.js 都不够。
已知限制
- 正文上限 100000 字符、单卡名 100 字符、最多 50 张卡;超限拒绝保存并给出原因,
不做静默截断。全局设置共用这个正文上限。
- 卡片是全局的,不分会话/工作区;全局设置同样只有一段,不能按会话覆盖
(要按会话区分就用人设卡)。
- 没有排序、导入导出、md 文件导入。