dsh-agent-profiles —— 智能体配置
English summary — Multiple AI agent profiles for DSH (DeepSeek Harness): role, avatar, personality, speaking style, ground rules and long-term memory — one global default, plus a per-conversation override with its own instructions and memory. Install: reference this folder from your DSH profile (
"dsh-agent-profiles": "link:<your dir>/dsh-agent-profiles"), then restart DSH for the host half and reload the page for the client half. Highlights: choose the active profile, pin a profile per conversation, memory-injection toggle, import / export, restore defaults. Language: the settings UI follows the host language setting — Chinese and English dictionaries are both built in. Model-facing prompts stay in Chinese by design. License: MIT.
多套 AI 智能体配置:角色设定 / 性格特点 / 说话习惯 / 行为准则 / 长期记忆摘要 / 记忆条目。
两层作用范围:
| 层 | 在哪改 | 作用范围 |
|---|---|---|
| 全局当前配置 | 设置 →「智能体配置」 | 没做专属设定的对话都用它 |
| 每个对话的配置 | 输入框左侧下拉 + ⚙ 面板 | 只影响这一个对话;可固定某套配置,还能写本对话专属的补充设定与记忆 |
无论哪一层,切换都是立即生效且只影响之后的对话,历史记录一个字都不动。
界面
设置 →「智能体配置」

设置页:左栏配置列表(新建 / 复制 / 导入 / 导出),右栏编辑名称、头像与角色设定。 The settings page: the profile list on the left, and the name / avatar / role fields on the right.
输入框左侧的快速切换器

快速切换器:给这个对话挑一套配置 ——「跟随全局」表示用设置页里当前那一套。 The quick switcher: pick a profile for this conversation; "follow global" means the active profile from the settings page.
回合头的头像与名称

回合头:本插件在官方「已完成 …」那一行上方加一行「[头像] 配置名」,官方那一行原样保留。 The turn head: this plugin adds an "[avatar] profile name" line above the stock "completed …" row, leaving that row untouched.
长期记忆条目

长期记忆:逐条编辑*长期记忆条目(每条自带「编辑 / 删除」),下方实时显示「长期记忆占用 746 / 5000 字;超过上限会自动提炼压缩」。* Long-term memory: edit the long-term memory entries one by one (each with its own edit / delete), with a live "long-term memory uses 746 / 5000 characters; past the cap it is condensed automatically" readout below.
截图为中文界面下的实测界面(设置 UI 跟随宿主语言,内置中英两套词典)。 The screenshots show the UI in Chinese (the settings UI follows the host language; both dictionaries ship built in).
1. 整体结构与模块划分
dsh-agent-profiles/
├── package.json ── ① 包清单:bundle patch + dsh.client 声明 + meta/icon
├── cordis.patch.yml ── ① 装配:插入 agent-profiles 行,硬依赖 webServer
├── icon.svg ── ① 插件卡片图标
├── locale/zh.json,en.json── ② 元数据文案(宿主读插件卡片用)
└── lib/
├── store.js ── ③ 数据模型 + 持久化 + 导入导出 + 变更算子
├── prompt.js ── ④ 配置 → 注入文本(记忆开关、优先级声明、截断)
├── routes.js ── ⑤ Host HTTP API(/__ap/*)+ index.html 脚本注入
├── index.js ── ⑥ Host 入口:装配 store/prompt/routes + 注册提示词段落
└── client.js ── ⑦ 浏览器半:设置页(分栏)+ 输入框快速切换器
数据流
┌──────────────── Host 进程 ────────────────┐
设置页 / 快速切换器 │ │
(浏览器) │ routes.js /__ap/state /__ap/command │
│ fetch │ │ /__ap/preview /__ap/export
└──────────────►│ ▼ │
│ store.js ──► profiles.json(原子写) │
│ │ │
│ │ 每次组装提示词时现算 │
│ ▼ │
│ prompt.js ──► systemPrompt.section( │
│ text: () => renderActive)│
└────────────────────────────────────────────┘
│
▼
模型看到的本轮系统提示词
为什么这么切
| 模块 | 单独成层的理由 |
|---|---|
store.js |
「配置长什么样、存哪、坏了怎么办」的生命周期是磁盘;与「怎么渲染成提示词」「怎么传给界面」完全不同步 |
prompt.js |
这是产品文案层:段落顺序、空字段省略、记忆开关、截断说明都会随反馈反复改;改它不该碰到数据与传输代码 |
routes.js |
传输与鉴权(同源守卫、体积上限、方法限制)是一组独立关注点,与业务算子分开才好测 |
index.js |
只做装配与降级(缺服务时怎么办),本身不含业务规则 |
client.js |
浏览器半必须自包含:不能 require 任何 Harness Client 包(见下) |
2. 关键设计决策
① 提示词段落交的是「函数」,不是「字符串」
systemPrompt.section({
name: "agent-profiles:persona",
order: 860,
text: () => renderActive(store), // ← 每次组装现算
});
字符串在注册那一刻就定死了,之后切换配置不会生效(重复注册同名段落会抛错)。
交函数则每次组装读一次当前的 activeId,于是:
- 切换配置 ⇒ 下一次组装就是新配置 ⇒ 立即生效;
- 历史消息存在会话日志里,本插件从不写它 ⇒ 历史不受影响。
这两条不是顺手做到的,是把渲染做成函数直接换来的性质。
② systemPrompt 不进 inject,走 ctx.get() 软探测
硬依赖的语义是「缺了就整个插件停在 pending」。缺 systemPrompt 会让设置界面也不出现,
用户连「为什么没有界面」都无从判断。所以:注入是增强,界面是本体,本体不该被增强拖死。
(webServer 是硬依赖 —— 没它连界面都发不出去。)
③ 「每个对话一套」靠 agent 作用域的同名段落,不靠全局开关
全局段落只知道全局配置 —— 它拿不到「现在在跟哪个对话说话」。所以每个对话要有一个
挂在该 agent 自己 ctx 上的段落:
ctx.on("agent/created", ({ agent }) => { // agent.id 就是 sessionId
agentCtx.effect(() => systemPrompt.section({
name: "agent-profiles:persona", // ← 与全局段落**同名**
order: 860,
text: () => renderForSession(store, sessionId),
}));
});
官方规则是「作用域段落压过同名全局段落」,于是:
- 有 agent 作用域段落 ⇒ 用这个对话的绑定与专属设定;
- 没有(非 agent 组装、或
agent.ctx拿不到)⇒ 回落到全局段落的全局配置。
agent/created 是 await 的(早于第一步组装),所以注册一定赶在第一次注入之前完成;
拿不到 agent.ctx 时只降级 + 记一条 warning,绝不抛错 ——
那个事件抛错会让 agent 创建失败,比「本对话设定没生效」严重得多。
两层各自的组装次数都会记进诊断(本对话段 SN 次 / 全局兜底段 GN 次),
设置页能直接看到 —— 「每个对话独立注入」不是靠信念,是靠这个计数。
④ 出厂默认就带着一套准则
默认启用的「通用助手」是填好内容的(用户明确要求):实事求是、简洁直白、不猜想臆想、
服从现行的 AI 内容规范与平台审核要求、做没做好都如实说。出厂即生效 —— 新会话里默认助手
就按这套说话,不用先手动配置。
早期版本这里是刻意留空的,理由是「装一个插件不该悄悄改变用户助手的行为」。
用户看过实际效果后明确要求给默认助手写准则,于是有意填上。
想回到零注入:把这一套的四个内容字段清空即可,hasContent()会自动让它不进提示词
(注入与技能目录都会跳过它)。
测试里把「默认准则必须含这五条」钉成了断言 —— 谁清空,谁就要面对红灯。
④c 长期记忆的自动化:工具 + 每 5 回合后台提炼 + 超限压缩
用户选择的是两者都要:模型能主动存,插件再定期兜底。
① 工具 memory_save 模型觉得值得长期记住 → 当场写进「本对话生效的那套配置」
② 后台提炼(每 5 回合) 插件读对话 → 调一次模型 → 把要点存进去
③ 超限压缩 长期记忆 > 5000 字 → 调模型合并成「摘要 + 精简条目」
触发点是 agent/status 变成 idle(一个回合跑完)——
不能挂 running,那是回合开始,内容还没产生。
写入目标按会话 id 当场解析(会话绑定优先,否则全局当前),
绝不缓存「当前配置 id」:用户在对话中途切换配置时,缓存会把记忆写错地方。
为什么压缩必须由插件做、不能交给模型自觉:模型不知道「记忆满了」。
预算判断只能由持有数据的一方做。
四条硬约束(每条都对应一种「把会话弄坏」的形态,都有测试):
- 绝不抛错到调用方。后台跑的代码,任何异常都不该影响用户正在进行的对话;
所有对外调用包 try/catch,失败只记日志。 - 绝不并发。同一会话同一时刻只跑一次;否则两次跑起来会重复写同一批记忆。
- 输入必须有上限。送进模型的对话摘录截断到 6000 字 —— 那是用户的钱。
- 输出必须能解析。要求模型「一行一条」,不用 JSON:JSON 一旦格式跑偏整批丢失,
行文本最坏只是存进一条废话。压缩结果为空时保持原样,绝不冒险覆盖。
缺服务要在装载时就说清楚(缺 sessionQuery / llm / tools 各一条告警):
静默跳过的现象是「功能好像没生效」,用户分不清是没触发还是没能力。
顺带修掉的一个真 bug:ProfileStore.command() 原来只回传 notice,
把算子自己的附加字段(memory.autoAdd 的 added)丢了 ——
现象是「记忆明明存进去了,日志和诊断里却什么都没有」。现在附加字段原样带出。
④b 空配置不注入、且记忆关闭时整块消失
只起了个名字的配置不产生注入(hasContent())。
关闭记忆注入时是整块不出现,而不是出现「(记忆已关闭)」——后者仍占上下文,
还会暗示模型「有记忆但你看不到」,让它开始猜记忆内容,比不注入更糟。
⑤ 客户端不 require 任何 Harness Client 包
官方 practices.md 明令禁止:那些包随版本变化、plain-JS 无类型检查,
一个抛错的组件会清空整个插槽条目。所以控件全部自己写,样式只用 --dsw-alias-* token
(token 改名最多让外观退化,不会让渲染炸掉),类名统一 ap- 前缀。
⑥ 首帧绝不返回 null
槽位账本把「渲染出内容的条目」记为 active。返回 null 的条目停在 active:false,
而首帧必然还没有数据(fetch 是异步的)—— 那就等于控件永远不显示。
所以加载中/失败都渲染占位控件。(这条是实测出来的,见第 5 节。)
⑦ 头像:配置项与渲染逻辑
配置项:profile.avatar —— 一个 data URL(data:image/(png|jpeg|webp|gif);base64,…),
空串表示「没设」。
- 为什么存 data URL 而不是落成图片文件:配置保持自包含 —— 导出即带走、
导入即完整,删配置不留孤儿文件,也不用再写一套文件清理。代价是体积。 - 所以上传时必须先缩:客户端用 canvas 把图缩到最长边 512px、webp 质量 0.92
再转 data URL(通常几十 KB)。手机照片 3~8MB 直接 base64 会让 profiles.json 膨胀到
几十 MB,而每次保存都要重写整份 JSON。- 早期是 192px / 0.85:回合头头像从 22px 放大到 44px 之后,192px 的源在高分屏上
明显发糊(用户反馈),所以源尺寸、质量、上限一起抬高。
- 早期是 192px / 0.85:回合头头像从 22px 放大到 44px 之后,192px 的源在高分屏上
- 服务端再兜一道:
LIMITS.avatar = 400000字符,超长 / 非白名单前缀一律落成空串。 - 明确拒绝 SVG:SVG 里能带脚本,等于给配置文件开一个 XSS 口子。
- 头像不进提示词(base64 不该烧模型上下文),跟着导出走,复制配置时一起复制。
渲染逻辑(三种状态必须分得清,这是需求里点名要处理的):
| 状态 | 判定 | 显示 |
|---|---|---|
| ① 设了头像 | avatar 非空 |
<img class="ap-avatar"> |
| ② 没设 | avatar === "" |
该配置的 emoji 图标(圆形底 + 居中) |
| ③ 设了但加载失败 | img.onError |
同样回落到 emoji 图标,不留破图 |
③ 记的是「哪一张坏了」而不是一个布尔值:否则用户换一张新图,
旧的失败标记还会把新头像挡掉。
⑦b 双通道:配置同时是技能
用户要的是「不要让一整套人设占满每次对话,把它做成技能」。选了 B:skill + 保留自动注入。
① 系统提示词注入(作用域段落) → 人设**一定**生效,模型什么都不用做
② 技能目录(skill provider) → 同一份配置也能按需 `skill` 加载原文,
技能列表里还能看到这台机器上有哪些智能体
为什么用 registerProvider 而不是 register:register({ content }) 的正文是
注册那一刻的字符串快照 —— 用户改完配置,技能里还是旧内容。而 provider 的get() 是真正被加载时才调用,正文按当下配置现算;再配 control.invalidate()
(配置变更时广播)让目录重算名字与描述。
ctx.skills.registerProvider((control) => ({
name: "agent-profiles",
async list() { return { candidates: skillCandidates(store), complete: true }; },
async get(candidate) {
const profile = store.profileById(candidate?.locator?.profileId);
return { ...candidate, content: renderProfile(profile) }; // 加载时才渲染
},
}));
目录只收「有内容」的配置:空配置加载出来是一段废话,而技能目录会进每次会话的
系统提示词 —— 这条路径上「少即是多」。描述也压到一行 80 字以内(角色设定首句)。
两条硬约束(都有测试):
list()/get()绝对不能抛错。技能目录是系统提示词的一部分,它抛错会连累
整个目录 —— 那是「一个插件弄坏所有会话」的量级。两个方法内部全部 try/catch,
失败就退化成「没有这个技能」;连「候选对象取值就抛错」这种输入也验过。skills走ctx.get()软探测:缺这个服务只是没有技能通道,注入与界面照常。
技能名:agent-profile-<slug>,kebab-case。配置名常是中文,去掉非 ASCII 后会是
空串 —— 那时退回用配置 id 拼一个稳定的名字(技能名会被会话引用,不能这次 A 下次 B)。
配置变更广播:ProfileStore.onChange() 在落盘成功后才广播(失败已回滚,那时广播
会让目录算出不存在的内容);每个订阅者单独 try/catch —— 一个订阅者抛错不该让
「配置存不进去」。
⑦c 回合头:[头像] 名称 贴在官方「已完成 …」那一行上方
目标版式(用户给的标注):
[头像] WorkBuddy ← 本插件加的
已完成 5m42s › ← 官方 turn-process 那一行,原样
两个问题都修了…… ← 正文
为什么不能「加个座位」了事:conversation.chat.node 是 keyed 槽,
19 个 key 全被官方占用,那一行是官方的 turn-process 渲染器;
它底下的加法座位只有 4 个(tool.call.toolview / assistant-actions /commandview / turnTail),没有一个在那一行里面,整棵槽位树里也没有任何消息级「左侧/头部」座位(搜过 avatar / leading)。
做法:优先级顶替 + 包一层官方渲染器(不是重写)
槽位系统明确支持这件事 —— keyed 槽同 key 同 priority 会直接抛错并告诉你怎么办:
keyed slot "conversation.chat.node" already has an entry for key "turn-process"
at priority 0 — register at a different priority to shadow it (lowest renders)
于是:
// ① 拿到官方那个渲染器(公开方法,不是掏私有字段)
const official = ctx.get("slots").entries("conversation.chat.node")
.find((e) => e.options?.key === "turn-process" && e.component !== TurnHead)?.component;
// ② 用更小的 priority 顶替(官方是 0)
ctx.slots.register({ name: "conversation.chat.node", key: "turn-process", priority: -10 }, TurnHead);
// ③ 我的组件 = 我的头像头 + 官方组件原样,返回 Fragment(不额外包 div)
return h(Fragment, null,
h(ApBoundary, { compact: true }, h(TurnHeadBadge, props)), // 只有我这一块在错误边界里
h(official, props)); // 官方那一行,同一个 props
entries(key) 是 SlotRegistry 上的公开方法(转发到 SlotCore.entries,带 JSDoc),
返回的条目带 component —— 不是去掏 renderer 的私有 _core(前一版我就是被这个挡住了)。
五道保险,失败形态是「没有头像」而不是「那一行没了」:
- 能力探测:取不到官方组件就根本不注册 → 那一行保持官方原样;
- 订阅重试:本插件可能比 ui-chat 先加载,那时官方条目还没出现 ——
订阅conversation.chat.node的注册变化,等它出现再接管
(没有这一步,加载顺序会让功能静默失效); - 注册时抓一份官方组件的引用做兜底,渲染时再解析失败也不会渲染出空;
- 我自己的那部分单独关在错误边界里,官方渲染器不在边界内 ——
我的代码再错也影响不到官方那一行; - 返回 Fragment:不额外包 div,我的头只是官方行前面多出来的一个兄弟节点,
不改动任何既有布局关系。
可逆:注销我这条注册,官方 priority 0 那条自动回到渲染位。
尺寸:头像 44px、名称 13px。用户明确要求「头像大约两倍大、文字不变」——
早期是 22px,在高分屏上偏小;44px 也是设置页编辑器里预览的尺寸,两处一致。
回合尾那一份已去掉(0.6.0):早期还在 conversation.chat.turnTail 注册过一份
同样的「头像 + 名称」,用户看完实际效果后明确说「回合尾的就不用了」——
回合头那份已经把「这条是谁写的」说清楚了,末尾再来一个是重复。
代码里留了注释说明这是有意去掉的,并且测试把「不再注册 turnTail」钉成了断言。
用哪套配置:本对话当前生效的那套(会话绑定优先,否则全局当前)。
不给历史消息记录「当时用的是哪套」——那要写会话日志,而本插件从第一天起
就不写会话日志(需求:不影响历史记录)。
3. HTTP API
所有路由挂在宿主的浏览器载体上,带同源守卫。规则是「硬证据只用于否决,不用于放行」:Sec-Fetch-Site: cross-site 拒绝;有 Origin/Referer 必须与 Host 一致;
都没有则放行(非浏览器上下文,例如 curl 探活)。
| 方法与路径 | 作用 |
|---|---|
GET /__ap/state |
整份状态 + 元信息(数据目录、计数、注入诊断) |
POST /__ap/command |
所有变更的唯一入口,body 是 {op, ...},返回整份新状态 |
POST /__ap/preview |
渲染一份草稿配置(未保存也能看注入长什么样) |
GET /__ap/export |
导出信封(可再导入) |
GET /__ap/client.js |
客户端 bundle |
变更算子
op |
参数 | 说明 |
|---|---|---|
profile.create |
profile? |
新建并自动设为当前(下一步必然要编辑它) |
profile.update |
id, patch |
保存编辑区内容 |
profile.duplicate |
id |
复制(含记忆,全部换新 id) |
profile.delete |
id |
至少保留一套,删当前则自动切到第一套 |
profile.activate |
id |
切换当前配置 |
memory.add / memory.update / memory.remove / memory.clear |
profileId, … |
记忆条目级操作(配置自带记忆) |
session.set |
sessionId, patch: {profileId?, note?, memories?} |
本对话的设定:绑哪套配置 + 补充设定 + 专属记忆 |
session.clear |
sessionId |
清掉本对话的设定,改回跟随全局 |
sessions.clearAll |
— | 清掉所有对话的设定(配置本身不动) |
state.import |
payload, mode: "merge"|"replace" |
导入信封或裸状态;合并时撞 id 自动改号 |
state.restoreDefaults |
— | 恢复出厂三套(并清空各对话设定) |
界面上的记忆编辑走草稿 +
profile.update/session.set(与其它字段同一套语义:改完点保存);memory.*这一组是给程序化调用准备的等价入口。
会话设定的三条语义
- 空即删除:
profileId=null且补充设定为空且没有记忆 ⇒ 这条记录被删掉。 否则「有几个对话做了专属设定」这个数字会把所有点开过的对话都算进去,立刻失去意义。 - 导出不含会话:
sessionId是本机的,导出去只是孤儿绑定。 - 删配置自动解绑:删掉一套配置时,指向它的对话会改回跟随全局,并在提示里说明影响了几个对话 —— 否则那些对话会绑到一个不存在的配置,注入静默回落而界面还显示「已固定」。
4. 持久化
- 位置:
<安装根>/.dsh-agent-profiles-data/profiles.json(卸载重装插件不丢)。 可用环境变量DSH_AGENT_PROFILES_DATA覆盖。 - 原子写:
tmp→rename。否则崩溃留下半截 JSON,下次读会走「回退默认值」, 用户所有配置凭空消失。 - 写失败回滚内存:先落盘、成功才换内存。反过来的话界面显示「已保存」而重启后回退, 等于骗用户。
- 变更串行:设置页与快速切换器可能同时写;并发读改写会丢更新,所以所有变更走一条 promise 链。
- 读不出来也必须能起来:文件损坏 / 字段类型错 / 手改坏了 JSON,一律回退默认值并记下
lastError(界面显示),绝不抛错阻塞会话。
5. 事故与修复
5.1 设置页一片空白(0.1.1)
症状:设置里能点开「智能体配置」,但页面是空的 —— 没有列表、没有表单, 连当前启用的是哪套都看不到;控制台也没有任何报错。
根因:客户端把 react/jsx-runtime 的 jsx() 当 React.createElement() 用了。
两者签名完全不同:
createElement(type, props, ...children) // 子元素是变长参数
jsx(type, props, maybeKey) // 第三个参数是 key!
于是 h("div", {className:"x"}, childA, childB) 里的 childA 被当成 key,
childB 直接丢弃 —— 组件照常返回、不抛错、控制台一行提示都没有,
只是渲染出一个空壳。jsx(Fragment, null, child) 更直接抛
TypeError: Cannot convert undefined or null to object。
实测证据(test/jsx-probe.mjs,可直接跑):
jsx(type, props, child1, child2):
props.children = undefined ← 子元素全没了
key = "[object Object]" ← 被当成了 key
createElement(type, props, child1, child2):
children 长度 = 2
官方模板用的就是 React.createElement(templates/decoration/client.js),
是我当初偏离了模板。已改回 React.createElement。
为什么之前没发现:这个失败模式完全静默。
「渲染出空壳」和「渲染正常但没数据」在界面上长得一模一样,
而当时唯一的验证手段是「插槽里有我的占用者」—— 占用者是注册出来的,空壳也有占用者。
所以补了 test/client-render.test.mjs:接管 React 的 hook 派发器 + 一个极简渲染器,
把组件真的跑一遍,再按元素树断言内容。
同时补的防线:ApBoundary 错误边界(class 组件 + getDerivedStateFromError)。
官方机制下组件抛错会清空整个插槽条目,用户看到的还是一片空白;
有了它,任何渲染异常都会变成界面上一段可读的报错 + 堆栈。
顺带修的隐性问题:组件里大量 tt("field.roleHint", "") —— 显式兜底是空串。
一旦 locale 注册没成功,tt 就返回那个空串,于是标签还在、说明文字整片消失。
现在 tt 会回落到内置中文词典:最坏情况是显示中文,而不是显示空白。
5.2 错误边界包装层把 props 吞了(0.2.0)
为了不让渲染异常清空插槽,两个条目外面各套了一层 ApBoundary。但包装层是这么写的:
function QuickSwitcherGuarded() { // ← 没有形参
return h(ApBoundary, { compact: true }, h(QuickSwitcher)); // ← 也没往下传
}
宿主把标准属性(conversation.input.left 的 sessionId)当 props 传给注册的组件,
被这层包装吃掉之后,里层组件永远拿不到会话 id ——
表现是「每个对话的设定」整个功能静默失效:界面还在、不报错,只是永远显示「跟随全局」。
已改成 function QuickSwitcherGuarded(props) { … h(QuickSwitcher, props) }。
这个 bug 是 test/client-render.test.mjs 逮住的(它给控件传 sessionId 并断言下拉的值),
也是「包装组件必须有对应断言」的由来。
5.3 让会话不可用:effect 注销器返回了 undefined(0.2.1)
症状(用户原话:会话用不了、从左侧列表消失、会话界面显示不可用):
[agent-registry] agent "session-…": agent/disposed listener threw:
TypeError: Cannot read properties of undefined (reading 'catch')
整份日志里第一次出现就在 0.2.0 加载后的第 1 秒,此前从未有过。
根因:agent/created 里我是这么注册 per-agent 段落的:
const dispose = agentCtx.effect(() => systemPrompt.section({ … }));
systemPrompt.section() 的清理是同步的。cordis 的 effect 只有在
「清理链里有 thenable」时才让注销器返回 Promise(Fiber.effect 里
task ? disposeAfter(task) : dispose(),而 dispose() 在没有 thenable 时返回 undefined)。
宿主销毁 agent 时对注销器的返回值调 .catch(),于是 undefined.catch(...) 炸掉,
被记成「agent/disposed listener threw」——错误信息里一个字都没提插件。
修复:把清理变成异步,注销器就必然返回 thenable:
return () => Promise.resolve().then(() => disposeSection?.()); // ← 别改回同步
用真 cordis 复现宿主的调用姿势(test/cordis-effect-probe.mjs):
旧写法 () => someDisposer() → TypeError: Cannot read properties of undefined (reading 'catch')
新写法 () => Promise.resolve().then(() => someDisposer()) → 宿主拿到了 thenable,没抛错 ✓
为什么之前没测出来:这个 bug 只在销毁时炸,注册那一刻一切正常;
store / prompt / routes / client-render 四个套件全都碰不到它。
所以补了 test/host-wiring.test.mjs:用假 ctx 把 apply() 跑起来,
检查注册出来的东西长什么样——尤其是那条「清理函数必须返回 thenable」。
两道防线(都在 0.2.1 里):
- 注册整体包在
try/catch里:注册失败只降级为全局注入 + 记 warning, 绝不让它冒泡(agent/created抛错会让 agent 创建失败,比功能失效严重得多)。 - 逃生开关:环境变量
DSH_AGENT_PROFILES_NO_SESSION_SCOPE=1时只保留全局注入, 不必卸载插件就能关掉这段有风险的注册。
5.4 面板透明、字看不清:token 名根本不存在(0.2.2)
症状:点开「本对话的智能体」浮层,背景透明,后面的对话正文叠上来,看不清。
根因不是透明度写错,是 token 名不存在:我写的是
background: var(--dsw-alias-bg-base-secondary),而这个部署的主题注册表里
没有这个名字 ⇒ 整条声明被浏览器丢弃 ⇒ 背景透明。
这类错误的可怕之处:没有任何报错。CSS 变量名写错,浏览器安静地忽略那一行; 组件照常渲染、测试套件全过,只有人眼在「浮层压在有字的内容上」时才会发现。
修法(换成主题里真实存在、语义正确的 token):
| 用途 | 原来(不存在) | 现在 |
|---|---|---|
| 面板 / 列表这类抬升面 | bg-base-secondary |
--dsw-alias-bg-layer-1 |
| 输入框 / 按钮 / 徽标这类嵌套面 | bg-base-secondary -tertiary |
--dsw-alias-bg-layer-2 |
| 悬停 | bg-base-tertiary |
--dsw-alias-interactive-bg-hover |
| 选中 | bg-base-tertiary |
--dsw-alias-interactive-bg-active |
| 浮层(必须实心) | bg-base-secondary |
background-color: bg-base + background-image: linear-gradient(bg-overlay, bg-overlay) |
浮层那行是两层叠加:不透明的 bg-base 打底,再叠主题的浮层色
(--dsw-alias-bg-overlay,官方描述就是 "Overlay and popover background")。
即使主题把浮层色定义成带 alpha 的,底色也保证它实心。
防回归:新增 test/theme-tokens.test.mjs —— 把 CSS 里用到的每个 --dsw-*
与 cordis_inspect_query(client / Theme / listTokens)返回的注册表逐一核对,
并额外断言浮层必须是不透明两层写法。写错一个变量名,测试直接失败。
5.5 每个对话的注入其实一直没生效(0.3.0)
症状:配置能存、预览能看,但每个对话的专属设定从来没进过提示词 —— 一律回落全局。 界面上完全看不出来。宿主日志在喊(这是唯一线索):
[W] [agent-profiles] 本对话的段落注册失败,本次降级为全局注入:
prompt section "agent-profiles:persona" is already registered
(for a per-agent override, register through that agent's `agent.ctx` instead)
根因:我用的是插件 ctx 上取的那个 systemPrompt:
const systemPrompt = ctx.get("systemPrompt"); // ← 插件作用域
ctx.on("agent/created", ({ agent }) => {
agent.ctx.effect(() => systemPrompt.section({ … })); // ← 代理把注册落回插件层
});
cordis 的服务代理是按「取它的那个 ctx」绑定作用域的。用插件的 ctx 取到的服务去注册,
落点是插件层,于是和全局那段同名冲突 —— 宿主那句提示就是答案:
「要按 agent 覆盖,就从那个 agent 的 agent.ctx 注册」。
修复:
const scopedSystemPrompt = agentCtx.get("systemPrompt"); // ← 从 agent 自己的 ctx 取
agentCtx.effect(() => scopedSystemPrompt.section({ … }));
为什么四个测试套件都没抓到:假的 systemPrompt 允许重复注册、假的 agentCtx 没有
systemPrompt,于是「注册落错作用域」在测试里根本不可能失败。
现在 test/host-wiring.test.mjs 的假对象会像宿主一样拒绝同层重名,
并断言「全局层只有全局那一段、作用域那段落在 agent 作用域里」。
改完这条假规则,测试立刻抓出了另一处我自己的错误假设 —— 说明它现在真的在干活。
教训:日志里那条 [W] 是唯一能发现它的地方。这类「静默降级」的设计有个副作用 ——
降级日志不打,功能没了你也不知道。所以关键降级路径必须留一行可查的日志。
6. 验证状态
| 项 | 结果 |
|---|---|
| 安装 | application: applied,warnings: [],无构建脚本待批 |
| 行状态 | include:agent-profiles → enabled: true, fiberPhase: "active" |
| Host 启动日志 | [agent-profiles] 就绪:3 套配置,当前「通用助手」,数据 <安装根>\.dsh-agent-profiles-data\profiles.json,无任何告警/报错 |
| 数据层自测 | test/store.test.mjs → 176 项断言全过(垃圾输入、损坏文件、删最后一套、导入合并撞 id、并发写、写失败回滚、上限截断、会话绑定 / 清空即删除 / 悬空绑定清理 / 按最近淘汰 / 删配置自动解绑 / 导出不含会话 / 落盘读回、注入文本三层合成,以及头像白名单(PNG/JPEG/WebP/GIF 接受,SVG 与伪 data URL 拒绝)、长度上限、副本携带、导出携带、不进提示词) |
| Host API 自测 | test/routes.test.mjs → 84 项断言全过(真实处理器 + mock req/res:状态读取、变更分发、非法输入、413/405、同源守卫四分支、预览、导出往返、脚本投递、index 注入幂等、注销,以及 session.set / clear / clearAll 的完整往返与拒绝路径) |
| 长期记忆自测 | test/memory.test.mjs → 70 项断言全过:后台提炼到第 5 回合触发并写入、送进模型的摘录有长度上限、调模型失败与「压缩结果为空」都只告警不写脏数据、拿不到 llm / sessionQuery / tools 只降级不抛、同一会话并发触发不重复写、写入目标必须是本对话生效的那套配置(切了配置不能写错地方)、记忆工具的描述与渲染文案(含 isConcurrencySafe() === false) |
| 界面渲染自测 | test/client-render.test.mjs → 124 项断言全过:首帧非空、有数据后完整渲染、点左栏另一套配置编辑区跟着换、带 sessionId 时下拉与本对话面板取值、头像三态(设了 → <img>;没设 → emoji 图标;onError → 回落到图标且不留破图)、助手消息条目按会话绑定取配置(未绑定回落全局)、接口失败显示原因、错误边界兜得住、幂等栅栏生效 |
| Host 接线自测 | test/host-wiring.test.mjs → 56 项断言全过:全局段落注册、agent/created 监听、清理函数必须返回 thenable(5.3 事故点)、作用域段落必须落在 agent 作用域而不是插件作用域(5.5 事故点)、拿不到 agent.ctx / 作用域里没有 systemPrompt / 注册抛错都只降级不冒泡、resume 先注销旧的、逃生开关生效、段落文本按会话取内容 |
| 主题 token 审计 | test/theme-tokens.test.mjs → 12 项断言全过:CSS 里用到的 11 个 token 全部在主题注册表内,且浮层是不透明两层写法(5.4 事故点) |
| 机制探针 | test/cordis-effect-probe.mjs → 用真 cordis 复现宿主的销毁姿势:旧写法抛 reading 'catch',新写法通过 |
| 客户端加载 | 渲染进程 HTTP 缓存里出现本插件 bundle(写入时刻 = 插件激活时刻);settings.section 占用者 agent-profiles(order 25) |
仍然要你确认的部分:
- 我看不到页面,也没有浏览器控制。修好的是「有没有东西」;观感(分栏比例、明暗主题)请你过一眼。
- 客户端 bundle 由宿主的 clientModules 提供(其响应可被缓存),所以新代码要刷新页面才生效; 若刷新后还是旧样子,重启一次 DSH 必定生效 —— 重启同时也会把更新过的 dsh-knowledge / dsh-theme-pack / dsh-team-chat 的 Host 半重新加载。
- 「模型确实收到了注入内容」有证据链,但要按对的方法查:桌面端 Web 服务在路由之前就做对端准入
(连
/都返回 403,curl 类探活进不来),所以要看会话日志 —— 事件system/message,字段data.message.content[0].text(用test/inspect-session.mjs搜注入文本(如「以下设定由用户」),别搜段落名 —— 段落名只在源码里,搜它必然 0 命中, 而 0 命中会被误读成「没注入」,我们就这么误判过一次)。 所以我把它做成你自己能看到的:设置页底部的「注入统计:已组装 N 次 · 其中空 EMPTY 次 · 最近 AT」,N由宿主每次组装提示词时累加 —— 它不是零,就说明注入链路真的在跑。 默认的「通用助手」自带内容,装好即生效,点「生成预览」就能看到。
7. 自测怎么跑
# 本机若没有独立的 node,可用 DSH 自带的 Electron 当 Node:
# $node = "<安装根>\DSH Desktop.exe"
# 本测试借官方 Host 目录里的 react,先指向它(否则测试会打印说明并以退出码 2 失败):
$env:DSH_APP_DIR = "<安装根>/resources/app"
$env:ELECTRON_RUN_AS_NODE = "1"
$node = "<安装根>\DSH Desktop.exe"
cd <本仓库>\dsh-agent-profiles
& $node test\store.test.mjs
& $node test\routes.test.mjs
& $node test\client-render.test.mjs
& $node test\jsx-probe.mjs # 复现 jsx-runtime 与 createElement 的签名差异
$env:DSH_APP_DIR = "" # 只对借 react 的那几个有用,跑完可清
test/inspect-session.mjs 是排查用的:把会话日志(.jsonl.zstd,多帧拼接)解开并按事件类型分组
统计关键词命中 —— 单看「出现几次」会被插件源码本身骗到(write 工具的参数就是文件内容)。
关于「重新安装」本地 bundle
本地 bundle 在 profile 里是 junction 链接(依赖写成 link:C:/...),所以:
- 改完源码,磁盘上就已经是新的了,不存在「需要重新拷进去」这一步;pnpm 那边只会说
Already up to date。 - 对已安装的本地 bundle 再调
install_bundle,管理器会返回application: "failed"+error.code: "ambiguous-install"—— 它无法判断这是「安装」还是「更新」,于是拒绝猜测。 这不是安装失败:list_bundles里它们仍是installed: true, enabled: true,行也都健在。 - 让改动真正跑起来只有两件事:Host 半需要重启 DSH(换模块代际),客户端半需要刷新页面。
No comments yet. Be the first to write one.