MoYu-CN/dsh-agent-profiles ↗★ 0
dsh-agent-profiles
Multiple AI agent profiles — role, avatar, personality, speaking style, ground rules and long-term memory — assignable per conversation, with a memory-injection toggle, import/export and restore-defaults. 适合需要多角色切换、个性化对话及记忆隔离的智能体用户。
설치
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:MoYu-CN/dsh-agent-profilesdsh-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:/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 智能体配置:角色设定 / 性格特点 / 说话习惯 / 行为准则 / 长期记忆摘要 / 记忆条目。
两层作用范围:
| 层 | 在哪改 | 作用范围 |
|---|---|---|
| 全局当前配置 | 设置 →「智能体配置」 | 没做专属设定的对话都用它 |
| 每个对话的配置 | 输入框左侧下拉 + ⚙ 面板 | 只影响这一个对话;可固定某套配置,还能写本对话专属的补充设定与记忆 |
无论哪一层,切换都是立即生效且只影响之后的对话,历史记录一个字都不动。
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 非空 | `` |
| ② 没设 | 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-,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)); // ←