dsh-team-chat
In-UI realtime collaboration room for Agent Teams: mailbox timeline, member avatars and @ dispatch. 适合需要多智能体协同工作并直观查看协作时间线的用户。
설치
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:MoYu-CN/dsh-team-chatdsh-team-chat
English summary — A multi-AI collaboration chatroom inside the existing DSH (DeepSeek Harness) UI: it restores the mailbox timeline that the stock Agent Teams wire projection drops, and adds member avatars and
@dispatch. Install: reference this folder from your DSH profile ("dsh-team-chat": "link:/dsh-team-chat"), then restart DSH for the host half and reload the page for the client half. Highlights: realtime mailbox timeline, member roster with avatars,@dispatch to one member, and thechatroom_read/chatroom_sendtools. 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.
给 DSH(DeepSeek Harness)桌面端写的插件:在现有界面内提供多 AI 实时协作聊天室。
它解决什么
DSH 官方的 Agent Teams 已经有具名 teammate、持久 mailbox、共享任务板 (带 compare-and-set 防互相覆盖)。但原生的 Web 面板有两个洞:
- 没有 mailbox timeline —— 官方 wire 投影只输出
{ members, tasks, failure }, 把消息丢掉了。数据其实完好地存在投影 state 的messages数组里。 - 任务板只读,面板不给任何修改控件。
本插件补上第 1 点:把官方丢掉的消息事件接回来,做成一条实时时间线。
原理(一句话)
注册一个平行的 session projection,监听同一个事件流里的
team/message/queued / team/message/delivered,攒成可直接渲染的条目,
再通过 wire.view 交给前端渲染(SSE 兜底)。
不修改官方包,只用公开 API —— ctx.sessionProjections.register() 是官方
自己的 agentTeam 投影在用的同一个入口,wire 字段也是官方契约。
官方升级不会冲突。
遵循的官方规范
本插件按 @deepseek-ai/dsh-agent-preset/skills/cordis-plugin-development/ 编写:
| 规则 | 落实 |
|---|---|
不 require 任何 Harness Client 包 | client.js 只 require("react") + require("react-dom")(两者都在 staticModules 种子里);开关控件照抄 primitive 实现,类名 tc-switch 前缀,只留 --dsw-alias-* token |
| UI 是 slot 里的 React 组件,不用 iframe | 走 conversation.view / conversation.session.header.actions / conversation.input.left / settings.general.item 四个 slot |
会话派生值走 Host 投影的 wire.view | timeline-projection.js 声明 wire: { viewSchema, view },Client 不自己折叠事件 |
apply 内用 ctx.effect 注册资源并返回清理 | 字典、五个 slot、权限影子都包在 effect 里 |
显示元数据放 locale/*.json + 顶层 icon | locale/en.json、locale/zh.json、icon.svg |
| 槽必须先声明才能注册 | 一律走 ctx.slots.inject(name, cb) 延迟注册;直接 register 未声明槽会抛 |
槽位目录是第一手资料
@deepseek-ai/dsh-cordis-client-runner/lib/client.js 里的 CLIENT_SLOT_API
列出了全部 89 个槽,每个都带 kind / scope / ownerProps /
standardProps / occupants / 官方范例代码 / declaredBy。
排查「这个槽能不能用、怎么用」直接查它,不要猜:
# 用 C:/wb-tmp/slot-catalog.mjs 抽取(composer | header | sidebar | always | detail )
node slot-catalog.mjs detail conversation.session.header.actions
当前状态
已安装进 DSH 并跑通。 已实现:
-
lib/timeline-projection.js—— 核心投影逻辑(纯函数 +wire.view) -
lib/member-store.js—— 成员资料持久化(显示名 + 职责 + 头像,原子写、永不抛错) -
lib/index.js—— host:投影 + SSE + 7 条路由 + 客户端注入 -
lib/client.js——conversation.view聊天室视图(带头像的时间线) -
lib/client.js—— 去掉权限按钮(影子conversation.input.permission,按会话门控) -
lib/client.js—— 「成员管理」按钮 → 打开右侧栏页签(sidebar.right.pane.tab) -
lib/client.js—— 成员编辑:显示名/职责/头像 + 添加 AI 成员 + 删除资料 -
lib/client.js—— 输入框工具栏左侧「发送到团队」成员选择器(conversation.input.left) -
lib/client.js—— 设置项:一键启用官方 Agent Teams -
package.json/cordis.patch.yml/locale/*/icon.svg
尚未做:
-
sidebar.panellist左侧「聊天室」按钮 - 右栏成员页签的真机 UI 实测 ——
openTab的时序依赖运行时挂载状态, 脚手架只能验到「注册契约正确」,必须人点一次确认 - 玩家从聊天室发言走真正的 Team 协议(当前只写官方 draft)
安装实测状态(2026-09-27 03:35):
✅ node_modules/dsh-team-chat -> D:/AI同步插件/dsh-team-chat
✅ profile bundles 含 dsh-team-chat
✅ dump-config 解析出 {id: team-chat, name: dsh-team-chat, inject: [webServer, sessionProjections]}
✅ 4 个端点全 200(config / client.js / timeline / members)
✅ 本次启动零 renderer 报错、零「without inject」、无「N entry did not activate」
⚠ "dsh-team-chat -> dsh-team-chat+0.1.0+4ff6a3b6edc7" 迁移本身成功了,
但同批次的 @local/dsh-ai-knowledge 拉了 npm 404,整批回滚 → 退回 link: 形态。
功能不受影响,只让 profile maintenance 冻结(见「安装」章节坑 ③)
成员菜单编辑功能(2026-09-27 第二轮)
铁律:成员身份不可改,只能改「展示层覆写」
官方 team/member 事件里的 name / provider / context 是不可变身份字段。
往 Team 协议层写回这些字段,官方会直接抛
teammate "..." changed immutable identity fields。
所以「改名字 / 改职责 / 换头像」一律只落 MemberStore(members.json),
客户端按 memberKey 查表覆写显示。memberKey 用稳定标识(teammate 名或 sessionId),
不用展示名 —— 改名字不能让资料丢失。
「职责」对应官方 roster row 的 description(spawn 时写的,上限 200)。
本地 duty 有值时盖过它,为空则回退官方描述:
duty: profile?.duty ? profile.duty : (description ?? "")
rosterOf() 必须把官方 description / avatar 一路带出来,否则回退没有数据源。
头像三态
emoji / text / image 三种。图片走 data URL(本地 FileReader 读,不落盘不上传),
两道 60 KiB 上限:客户端读文件时拦一次,宿主 normalizeMember 再拦一次。
宿主侧只认 data:image/(png|jpeg|gif|webp|avif);base64,...,外链和 data:text/* 一律丢弃 ——
避免把 `` 变成 SSRF / XSS 的入口。
清空头像是显式信号 avatar: null。set() 里要先把它摘出来再合并,
否则 {...previous, ...member} 会把它当普通属性写进磁盘,读回就是一条脏记录。
添加 AI 成员:必须走宿主代理
唯一的公开入口是宿主侧的 ctx.agentTeams.spawnTeammate(caller, request),准入链很硬:
caller必须是精确的 live Lead Agent 对象 ——TeamRoster.tryMembership里this.ctx.agents.get(agent.id) !== agent直接拒。 身份靠对象同一性,任何重建/缓存的副本都算过期引用。- 只有
role === "lead"能调,否则TEAM_LEAD_REQUIRED。 name必须匹配/^[a-z0-9]+(?:-[a-z0-9]+)*$/u、≤64、且不能是"lead"。description必填,≤200。
浏览器端拿不到宿主 Agent 对象(跨端),所以新增路由 POST /__teamchat/spawn:
宿主侧 findLiveAgent() 按 sessionId 找到同一个引用,再转调官方。
agentTeams 不进 inject:Cordis 的 inject 是硬依赖,服务不存在时 fiber 停在
pending,整个插件(含聊天室视图)都起不来。改用 ctx.get("agentTeams") 按需取,
缺失时返回 503 TEAM_UNAVAILABLE,客户端降级为「让 Lead 自己调 spawn_teammate」的提示。
官方错误码原样透传(TEAM_MEMBER_NAME_TAKEN / TEAM_MEMBER_LIMIT / ...),
客户端按 code 而非消息文本挑文案。
过渡动画
只用 transform / opacity(不碰 width/height,避免 layout thrash),
时长贴齐已有先例 .tc-switch-thumb { transition: transform 120ms ease; }:
.tc-panel { transform-origin: top right; animation: tc-panel-in 140ms ease both; }
@keyframes tc-panel-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } to { opacity: 1; transform: none; } }
.tc-stage { animation: tc-stage-in 150ms ease both; }
@keyframes tc-stage-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
面板三个阶段(list / edit / add)互斥,靠 React key 换元素触发 .tc-stage 重播动画。
Escape 键在非列表态先退回列表,再按一次才关闭面板。
@media (prefers-reduced-motion: reduce) 下动画全关。
坑
avatar: null别直接 spread 进 state(见上)。set()合并后只剩updatedAt时要当无效输入拒掉, 否则对一个没有资料的成员发{avatar: null}会在磁盘上留一条垃圾记录。@keyframes里的transform是合法的 —— 上面那条「只用 transform/opacity」 的断言如果写成扫全文件会误报,必须只扫@keyframes块内部。
第三轮(2026-09-27):会话守卫 / 右侧栏页签 / 头像大小限制
一、会话守卫:为什么槽注册必须「条件化」
症状:切回普通对话时,「发送到团队」按钮不变回去,权限选择器消失。
根因在 entriesOfSlot 的去重语义(dsh-client-ui-slots/lib/index.js:278-293):
// single 槽取 cell = undefined,同一个 cell 只保留第一个条目
也就是说 conversation.input.permission 是 single 槽,我们注册的
priority: -100 影子是第一个胜出者,永远赢过宿主默认 0 的那条。
组件里 return null 不能让位 —— 只会得到一块空白。
scope 字段也帮不上忙:它只是 store-handle 的校验声明
(同文件 :94),不按会话过滤 entries。注册一次就是全局生效。
唯一正解:跟随当前会话动态 register / dispose。
function gateOnTeamSession(ctx, register) {
let active = null;
const sync = (sessionId) => {
const want = isTeamSession(sessionId);
if (want === (active !== null)) return;
if (want) active = register();
else { const d = active; active = null; d?.(); }
};
const dispose = ctx.effect(() => {
const current = ctx.uiSession?.adapter?.current;
if (current === undefined || current === null) {
// 拿不到跟踪器 → **保守不注册**。
// 宁可少隐藏一个按钮,也不能把普通对话的权限按钮永久吃掉。
return () => {};
}
const unsubscribe = current.subscribe(() => sync(current.getSnapshot()?.key));
sync(current.getSnapshot()?.key);
return () => { unsubscribe(); active?.(); };
}, "team-chat: team-session gate");
return { dispose };
}
跟踪当前会话的官方姿势是 ctx.uiSession.adapter.current
(样板:dsh-client-ui-sidebar-right/lib/client.js:9036-9041),
snapshot 带 key(= sessionId)。
list 槽(conversation.input.left)不一样 —— 它按 id 分格子,
同一槽的其它条目各占各的,我们整块不渲染不遮挡别人。
所以那个走组件内部守卫:
const { isTeam } = useTeamContext(useSession, useSessions, sessionId);
// ⚠ 早退必须在所有 hook 之后,否则会话类型切换时 hook 数量变化,
// React 会抛 "Rendered fewer hooks than expected"。
if (!isTeam) return null;
useTeamContext 的判定:有 subagent.address.parentSessionId(是别人的子会话)
或有 agentTeam 投影(自己是 Lead) ⇒ 团队会话。
二、成员管理搬进右侧栏(需求 C)
用户要求:「成员管理不该放在模式右边,应放在对话框最右侧、侧边栏下方」。
走官方两段式路径(第三方注册页签类型是官方明确支持的):
// 第一段:注册页签**类型**(在 applyInner 里)
ctx.sidebarRightTabs.register({
id: "dsh-team-chat/members",
kind: "team-chat-members",
priority: "extension",
title: () => t("pane.title"),
guide: [{ id: "members", order: 40, title: () => t("pane.guideNew"),
description: () => t("pane.guideDesc") }],
});
// 第二段:注册页签**主体**与**标题**(keyed / session 槽,key = 上面的 id)
ctx.slots.inject("sidebar.right.pane.tab", () =>
ctx.slots.register({ name: "sidebar.right.pane.tab", key: PANE_ID, locale: NS,
inject: () => ({ t }) }, MembersPane));
ctx.slots.inject("sidebar.right.pane.tab.title", () =>
ctx.slots.register({ name: "sidebar.right.pane.tab.title", key: PANE_ID, locale: NS,
inject: () => ({ t }) }, MembersPaneTitle));
先例:dsh-client-ui-sidebar-terminal(inject 含 sidebarRightTabs,
注册 kind: "terminal" + 两个槽)。inject 里要加
"sidebarRight" / "sidebarRightTabs" / "layout" 三个服务名。
打开路径:官方右栏的 add 控件打开的是 guide 页,用户从 guide 卡片进具体页签
(GuideBody 的 onPick → tab.actions.openTab(kind, { replaceTab: true }))。
没有 guide 条目 = 页签类型注册了也没有任何 UI 能打开它。
头部保留一个「成员」按钮做一键直达。这里有个时序坑:
ctx.layout.openRightbar(); // 先展开右栏
ctx.sidebarRight.openTab(kind, ...); // 再开页签
openTab 内部走 this.require(),而 require() 在右栏未挂载时
直接抛 sidebarRight: no session surface is mounted
(dsh-client-ui-sidebar-right/lib/client.js:6750)。
挂载是下一帧的事,所以头一次失败要用 requestAnimationFrame 重试一次。
同一个面板组件两个宿主:浮层要 position: fixed + 阴影 + z-index,
右栏里是正常文档流。用 .tc-panel-embedded 覆盖掉浮层那套装饰,
MembersPanel 只负责内容,定位留在各自宿主。
三、头像文件硬限制 1 MiB(需求 A · 已改版)
原需求是「>1 MB 自动压缩到 1 MB 以内」,实现完成后用户改为「放弃压缩, 直接限制只能上传 1 MB 以内的图像文件」。压缩链路已整段删除**。**
触发点:MemberEditor.pickFile,在 FileReader 读进内存之前先量 file.size。
选文件
├─ 类型不在白名单(png/jpeg/gif/webp/avif/bmp)→ avatarBadType,不读文件
└─ 类型合法
├─ file.size > 1 MiB → 清空已选图 +
│ members.avatarTooLarge(带出实际体积,如「图片 2.31 MB,
│ 超过 1 MiB 上限,请换一张更小的。」)→ **到此为止,不读进内存**
└─ file.size
ctx.slots.register(
{ name: "conversation.input.permission", id: "team-chat-hide-permission", priority: -100, inject: () => ({}) },
() => null,
),
);
坑:不能注册在 priority: 0 —— 同优先级直接抛
single slot "..." already has a registration at priority 0 (registered by ...) — register at a different priority to shadow it (lowest renders)。
为什么不用 CSS 隐藏:那条路要依赖宿主内部类名(InputBar_module_css_default.modes),
宿主一改就失效,而且 display:none 仍占 DOM、仍有焦点序。槽的影子是官方机制。
2. 输入框不自绘
宿主的 composer(含文本框、文件上传、@ 路径补全、模型选择)原样复用,
本插件只影子掉权限按钮、往工具栏左侧加一个成员选择器。
2b. 工具栏左侧「发送到团队」(conversation.input.left)
用户要「上传文件、上传话语、@某个智能体」——但输入框不自绘,所以拆成两步:
- 文件上传 / 正文输入 → 官方 composer 自带,不碰
- @某个智能体 → 挂一个选择器到
conversation.input.left(官方描述:"Compact controls at the left of the composer tool row")
选中成员后往官方 draft 里写文本:
inputActions.setDraft((current) => `@${name} \n${current.trim()}`);
setDraft 是函数式更新 —— 签名是 setDraft(prev => next)。
写成 setDraft(text) 会覆盖掉用户同时在打的字。这一点没写在槽目录里,
是读官方 InputActions 类型定义确认的。
为什么只是写 draft、不直接发消息:宿主有 ctx.sessionActions,但没有公开的
「以 team member 身份发消息」入口。走非官方内部 API 会把插件钉死在某个 DSH 版本上,
所以这里只落一个清晰可读的指令文本,发送仍走官方通路。
id 不能撞:官方 client-ui-mention 也用 conversation.input.overlay,
id 是 "path"。写成一样就是 single 槽同优先级抛错,整个 entry 不激活。
2c. 在聊天室里直接发消息 = 只挂进房间(2026-10-12 修)
聊天室是「视图」,输入框是它所在那个会话的。 所以宿主只看得到「哪个会话来了一条 真人消息」,看不出用户此刻停在房间视图还是普通对话 —— 「没 @ 谁」在宿主那边一律按 私聊处理。结果用户在聊天室里说给所有人听的一句话,变成了某个窗口里的私信, 房间时间线上一个字都没有(用户原话:「它直接就发到私信窗口里了,而不是在聊天室展示」)。
修法分两半,缺一不可:
- 客户端(
TeamCompose,挂在输入框工具栏上 = 同一个窗口,只有它知道视图): 房间视图挂载期间把该会话的范围装填成ROOM_ONLY_TARGET("__room__"), 离开视图立刻清掉(target: null)。它不是一次性的:被@某成员/ 「所有人」覆盖后按覆盖值走,范围复位时再装填回来(team-send回执里那一段)。 按钮上的默认值也从「发送到团队」改显示为「只发到房间」。 - 宿主(
lib/index.js的onTeamSend):认哨兵 ⇒ 记一笔房间条目、 一条都不投递(delivered: true= 「本来就没人要收」,与chatroom_send的to:"room"同一口径),并且不消费哨兵。
同一条路还修了 /__teamchat/send 的收件人三态:不给 target / null / 哨兵 ⇒ 只挂房间,
空串 ⇒ 广播(「发给全体」语义不变),名字 ⇒ 只发那一个。改前「不给」被折成空串 =
广播给房里每个成员 —— 那正是「悄悄发到所有人私信窗口」的机制。
⚠ 残留(没有解决,如实记下):若用户在某个成员自己的对话窗口里打开聊天室视图, 那句话仍然会作为该会话的 user 消息被宿主投给那个成员(那是官方通路,插件拦不住、 也不该拦)。插件这边现在不会再额外投递,而且这条会照常出现在房间时间线上。
3. 右上角「成员」菜单
挂在 conversation.session.header.actions(list 槽,会渲染成
ConversationRoot_module_css_default.headerActions 里的按钮组)。
官方 dsh-experimental-client-ui-agent-team 的 TeamAction 就在这儿(order: -20),
本插件用 order: 200 排在它后面。
浮层用 react-dom 的 createPortal 挂到 document.body —— 头部有 overflow
裁剪,不 portal 出去菜单会被切掉。定位由 JS 算(右侧对齐触发器 + 视口夹取)。
4. 成员资料持久化
数据目录:\.dsh-team-chat-data\(DSH 安装目录下)。
放这里而不是 $DSH_HOME / profile,是为了卸载重装插件、切 profile 都不丢。
可用环境变量 DSH_TEAM_CHAT_DATA 覆盖(测试/多实例用)。
路由 /__teamchat/members:GET 全量、POST 写入(action: "set")/ 删除(action: "remove")。
路由 /__teamchat/spawn:POST,代理官方 agentTeams.spawnTeammate(见上文「添加 AI 成员」)。
合并语义:只改名字时不会把头像抹掉 —— 客户端和 MemberStore 两边一致。
唯一例外是头像:切形态或点「清除图片」时显式发 avatar: null。
头像三级降级:自定义 emoji/文字/图片 → 成员 ID(或名字)首字 → 色块圆。
颜色由 key 哈希算出(hueOf),保证同一成员每次渲染同色,
不用随机数(否则头像一跳一跳的)。
官方 Agent Teams 开关
装完本插件后,设置 → 通用里会多出一项「官方智能体团队」,
一键启用 @deepseek-ai/dsh-experimental-agent-team-profile,
不必自己去插件页翻。
实现走前端 Remote 桥 ctx.remote.pluginManager.setBundleEnabled(name, enabled) ——
不是在 host 侧注入 pluginManager。原因:
- host 的
pluginManager依赖profileContext(dsh-base/cordis.patch.yml:20), 缺失时会拖垮整个插件;前端 Remote 没这个问题 setBundleEnabled内部会await this.reload(...), 真正让改动生效(selectBundle只写文件,不重载)
不要用返回值的 installed 字段判断能否启用。
installed 来自 profile 的 dependencies,而随 DSH 分发的可选 bundle
(OPTIONAL_BUNDLES 成员,agent-team-profile 就是)从不写进那里——
它们靠安装锚点解析。本机实测:installed: false 但可解析、兼容性通过。
真正的不可用只有两种情况:listBundles 里没这一行,或该行带 error。
验证结果
七套验证脚手架(都在