MoYu-CN/dsh-team-chat ↗★ 0

dsh-team-chat

在界面内提供多智能体实时协作聊天室,支持成员头像与@调度。 适合需要多智能体协同工作并直观查看协作时间线的用户。

套件
dsh-team-chat
相容性
待驗證
版本
0.1.0
授權
MIT
最近更新
2026年10月12日

安裝

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:MoYu-CN/dsh-team-chat

dsh-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 the chatroom_read / chatroom_send tools. 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 面板有两个洞:

  1. 没有 mailbox timeline —— 官方 wire 投影只输出 { members, tasks, failure }, 把消息丢掉了。数据其实完好地存在投影 state 的 messages 数组里。
  2. 任务板只读,面板不给任何修改控件。

本插件补上第 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.viewtimeline-projection.js 声明 wire: { viewSchema, view },Client 不自己折叠事件
apply 内用 ctx.effect 注册资源并返回清理字典、五个 slot、权限影子都包在 effect 里
显示元数据放 locale/*.json + 顶层 iconlocale/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),准入链很硬:

  1. caller 必须是精确的 live Lead Agent 对象 —— TeamRoster.tryMembership 里 this.ctx.agents.get(agent.id) !== agent 直接拒。 身份靠对象同一性,任何重建/缓存的副本都算过期引用。
  2. 只有 role === "lead" 能调,否则 TEAM_LEAD_REQUIRED。
  3. name 必须匹配 /^[a-z0-9]+(?:-[a-z0-9]+)*$/u、≤64、且不能是 "lead"。
  4. 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。

验证结果

七套验证脚手架(都在