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()),其余引用一律拒绝保存并说明原因。
其中只有 {{model}} / {{provider}} 是 DSH 0.2.1 注册的官方提示词变量;{{cwd}}
不是(0.2.0 曾有,0.2.1 已移除)。本插件仍支持它,但由插件自己在渲染时从
agent.session.header.cwd 本地展开,两个段落都设了 interpolate: false,不会交给
DSH 官方插值。{{cwd}} 取的是会话 header 记录的原始工作目录,不是
working-directory 服务变更后的当前目录。
语义:设置页管仓库,会话管这次用哪张
| 层 | 存哪 | 作用 |
|---|---|---|
| 卡片 | 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
人设文件不会被读写或修改。
这些文件不在会话日志里。 卡片、全局设置、会话绑定都是插件自己的外部状态, session log 不记录“这一轮用了哪张卡”,所以 fork / replay / 换一台机器 / 删除
$DSH_HOME/persona-cards后都无法从日志重建绑定;插件会按“没绑定”处理,静默 不注入,而不是报错。存储格式(version 1)保持不变。
注入位置
两个提示词段落,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 0.2.1 变量变化(重要)
DSH 0.2.1 的 dsh-agent-loop 只注册 provider 和 model 两个提示词变量;
0.2.0 里的 cwd 已被移除。官方文档明确要求从 persona / preset 的 suffix 中删掉
Your working directory is {{cwd}}.,否则引用未注册变量会让提示词组装在模型请求前
直接失败。
本插件不受影响,因为:
- 两个提示词段落都用
interpolate: false渲染,DSH 不会扫描卡片正文里的{{...}}; {{cwd}}由renderVariables()从agent.session.header.cwd本地展开,缺值替换为 空串而不是抛错;- 落盘前的白名单校验沿用官方
interpolate()的语法,保证存下来的正文在别处(例如 被复制进会做官方插值的 preset 段落)也不会带雷。
不要把 interpolate 改回 true,也不要指望 DSH 替你展开 {{cwd}}。
{{cwd}} 是插件的本地变量:它取会话 header 的原始工作目录,不跟随
working-directory 服务后续的目录切换。
两个保命设计(别改)
{{...}}闸门。DSH 的官方插值会对每个段落扫{{...}},而interpolate()在三种情况下直接抛异常:引用了没注册的名字(unknown prompt variable)、 引用了已注册但本次组装没有值的变量、以及写法不合法 (malformed prompt variable reference)。0.2.1 只注册provider/model; 0.2.0 里还能用的{{cwd}}现在会让整个模型步骤失败。本插件的做法是关闭官方插值 + 本地展开 + 落盘前白名单校验:
- 两个段落都设
interpolate: false,DSH 不会碰正文里的{{...}}; - 渲染时只本地替换
model/cwd/provider,缺值给空串;cwd取自agent.session.header.cwd(会话创建时的原始目录,不是 working-directory 服务变更后的当前目录),它不是 DSH 官方变量; - 保存时用
scanVariableReferences()按官方interpolate()的语法扫正文 (包括「没有配对的单个{{当普通文本放行」),只放行这三个名字,其余引用 拒绝保存并说明原因。
全库扫过 DSH 的包(0.2.0 时是 289 个):官方自己没有任何内置段落引用这些 变量 —— 它们就是留给插件/组合层用的。0.2.1 起官方变量只剩
provider/model,cwd归本插件自己维护。- 两个段落都设
不能用
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 文件导入。
No comments yet. Be the first to write one.