侧栏会话 · Side chat
在 DSH(DeepSeek Harness)里仿照 Codex 的 /side:选中一段文字,在右侧栏开一个独立的会话讨论它,主会话一个字都不多。
- 选中文本 → 浮层出现两个胶囊:引用文本 / 侧栏会话
- 点「侧栏会话」→ 右侧栏开出一个空白会话,输入框里已经放好「父会话引用胶囊 + 选区引用」
- 侧栏里的对话不会出现在主会话的记录里;一条会话复用一个侧栏会话
- 关掉页签就结束,不归档、不阻塞,随时可再开
┌───────────────────────────────┬──────────────────────┐
│ 主会话 │ 侧栏会话(空白) │
│ 你: 整理参数化方法与论文… │ │
│ 助手: 两阶段策略:先保证双射… │ ┌────────────────┐ │
│ 你: ▓▓选中这段▓▓ │ │ @整理参数化… ▸ │ │ ← 父会话引用胶囊
│ ┌──────────┬──────────┐ │ │ > 选中的那段文字 │ │ ← 引用
│ │ 引用文本 │ 侧栏会话 │ │ └────────────────┘ │
│ └──────────┴──────────┘ │ 你: 这里为什么… │
└───────────────────────────────┴──────────────────────┘
目录
它做了什么
| 交互 | 结果 |
|---|---|
| 在对话里选中文字 | 浮层出现「引用文本」「侧栏会话」两个胶囊(替换掉原生「引用文本」单胶囊,行为不变) |
| 点「侧栏会话」 | Host 创建一个空白会话;右侧栏打开它的页签;输入框预填父会话引用 + 选区引用 |
| 再点一次 | 复用同一条侧栏会话,不新建 |
| 关闭页签 | 会话留在磁盘(平台无删除 API),但不在任何工作区里 |
| 空会话(还没发过消息) | 拒绝开启,提示“先发一条消息”(与 Codex /side 的守卫一致) |
侧栏页签标题固定为「侧栏会话」,地址是 dsh-resource://sidechat/session/<sessionId>,像普通页签一样可拖动、可拆分、可浮动。
为什么这样实现(平台约束推导)
Codex 的 /side 是“fork 出临时线程 + 视觉上从边界开始 + 结束后丢弃”。DSH 没有一个能同时满足这三点的原语,几条路都被平台规则堵死,这里是完整的推导:
1. 不能直接 fork。
sessionController.fork() 会 workspace.attachSession(childId),把子会话挂进父会话所在的工作区,并且 isSeeded: true 会把父会话到当前为止的 turn 全部搬进子会话日志 —— 左侧列表多一条、右侧栏重复显示整段历史。
2. 不能“fork 完就归档”。
归档不是列表过滤标记,而是硬闸门。@deepseek-ai/dsh-api-session-controller 的 ArchivedSessionGate:
ctx.on('agent/pre-step', (payload, next) => underArchivedSession(ctx, payload.agent)
? Promise.resolve({ kind: 'reject' }) : next());
被拒的 turn 以 reason: 'blocked' 结束、一次模型请求都不发,界面上表现为“已完成,用时 1 秒”而没有任何回复。
3. 不能靠 origin: 'subagent' 隐藏。
ui-workspace 的 sessionVisible() 第一行确实会隐藏这类会话(if (session.origin === 'subagent') return false),但代价是客户端把它当成子智能体的子会话:
- 输入区只读:
const continuable = subagent?.address.mode === 'continuable'—— 没有真实 activation 的会话mode永远是unknown,于是提示“此子智能体暂时只读”。 - 附件被拒:
Session.prompt()里带地址的会话遇到 file part 直接返回SUBAGENT_FILE_UNSUPPORTED,也就是“当前无法导入文件”。 - 消息根本发不出去:Host 侧
sessionController.prompt对 subagent-origin 会话抛session/agent-busy(“subagent Sessions require their durable parent address”),必须走subagents.prompt+ 真实 activation。
结论:在这套平台上,“从工作区隐藏”和“当普通会话用”目前是互斥的。
所以本插件的选择是:可用优先。
Host 创建一个普通空白会话(父会话的 cwd、不挂 workspace、部署默认模型),上下文用会话引用(session reference)承载 —— 只进模型、不进界面。代价是它出现在左侧“未归组”里,一条会话最多一条,靠复用来限制数量。
架构
两个半边,一个包:
.
├── index.js Host 半:一条 Fetch 路由,负责“造会话”
├── client.js Client 半:选区浮层 + 右侧栏页签类型 + 资源供给 + 预填
├── cordis.patch.yml bundle 补丁:插入 include:side-chat 这一行
├── package.json dsh.bundle + dsh.client 双面声明
├── locale/{zh,en}.json 插件管理器里的标题/描述
├── icon.svg
├── tools/ 自测脚本(见“开发与自测”)
├── LICENSE
└── README.md
Host 侧只做一件客户端做不到的事:用指定的 header 创建会话(ctx.agents.create)。其余全部在浏览器里完成。
选中文本
│
▼
client.js SelectionActions ──POST /api/side-chat/open──► index.js
│ ├─ 校验 parentSessionId
│ ├─ ctx.agents.get(parent)
│ └─ ctx.agents.create({ meta: { cwd } })
│◄──────────────── { sessionId } ─────────────────────┘
│
├─ 轮询 sessions.list 等 agent/created 公告到达
├─ localStorage['side-chat.v3.side-sessions'][parent] = child
└─ ctx.sidebarRight.openResource('dsh-resource://sidechat/session/<child>')
│
▼
资源提供者 open():sessions.retain(child) → 预填输入框 → 挂起等页签关闭
│
▼
页签体:SessionProvider(子会话) → renderFactorySlot('conversation.content')
数据流
- 选区:
uiConversation.interactions里只有message-quotes会响应trigger === 'selection',而浮层槽位是单格的 —— 所以本插件接管了quote这一格(priority: -100,keyed 槽位的规则是数字越低越渲染),渲染成一排两个胶囊,原名原行为保留引用。 - 取会话:POST 到 Host 路由;成功后在
localStorage记下父 → 子映射,并等待agent/created转成的api-session/added让目录认识这个 id(最多等 2 秒),避免sessions.retain早于目录更新而抛错。 - 开页签:
ctx.sidebarRight.openResource(address)。右侧栏是按会话分配的,所以页签挂在当前主会话的右栏里;地址里的会话 id 决定内容。 - 资源供给:
ctx.resources.register({ protocol: 'sidechat', open })。open()里sessions.retain,await reference.ready,预填输入框,然后await waitForAbort(signal);页签关闭 → signal abort →reference.release(),一个页签一份引用。 - 渲染:页签体声明一个 session 作用域的子席位
sidechat.seat(SessionProvider只会发给“声明了非 root 子席位”的注册),然后用renderFactorySlot('conversation.content', { variant: 'embedded', … }, { slots: { views } })托管宿主自己的会话内容 —— 这是ui-subagent的聊天页签同款配方。
用到的扩展点
| 扩展点 | 用途 | 位置 |
|---|---|---|
ctx.slots.register('conversation.chat.text-interaction', key: 'quote') |
选区浮层(两个胶囊) | client.js · SelectionActions |
ctx.uiConversation.interactions |
借用 Quote 的选区准入(不重复注册,避免抢浮层) | 同上 |
ctx.slots.register('sidebar.right.pane.tab', key: 'dsh-side-chat') |
右侧栏页签体 | client.js · SideChatTab |
ctx.sidebarRightTabs.register |
页签类型 sidechat + 地址 glob |
client.js · apply |
ctx.resources.register({ protocol: 'sidechat' }) |
地址 → 保留会话引用 | client.js · provider |
ctx.sidebarRight.openResource |
打开/聚焦页签 | client.js · openSideChat |
ctx.conversation.input.shell(id).actions |
预填输入框(setDraft / insertQuote) |
client.js · prefill |
ctx.connection.fetch.register |
Host 侧的创建会话入口(owner 专用) | index.js · apply |
ctx.agents.create |
用指定 header 造会话 | index.js · createSideSession |
上下文是怎么带过去的
侧栏会话本身是空白的(没有 seed),上下文靠一条 会话引用(session reference)跟着第一条用户消息走:
@[整理参数化方法与论文](dsh-session:InIgc2Vzc2lvbi0...)
- 这是
@deepseek-ai/dsh-session-reference的规范 mention 格式:@[标签](dsh-session:<JSON(sessionId) 的 base64url>)。 agent/pre-step里,dsh-session-reference会解析任意用户消息里的这种 mention,把被引用会话的快照(按字节上限裁剪)插在该消息之后,只在模型请求里出现。- 界面侧,
ui-conversation的sessionReferenceRule把这种 mention 渲染成一个会话小胶囊,所以聊天记录里看不到 base64 噪音,也不会重复整段历史。
也就是说:历史只给模型看,界面从空白开始 —— 这正是 Codex 侧栏会话的语义。
HTTP 协议
POST /api/side-chat/open
Content-Type: application/json
{ "parentSessionId": "session-..." }
| 状态 | 响应 | 含义 |
|---|---|---|
| 200 | { "sessionId": "session-..." } |
已创建(普通会话,cwd 取父会话,未挂 workspace) |
| 400 | { "error": "parent-session-required" } |
请求体不合法或超过 4 KiB |
| 409 | { "error": "…" } |
父会话不在世,或创建失败(错误原文透传) |
- 路由注册在 Web carrier 已拥有的共享
/api通道上,精确路由只有peer.principal.kind === 'owner'才会被执行,否则 403;Host/Origin 栅栏与 Cookie/Token 鉴权在路由之前已经完成。 - 请求体上限 4096 字节,只读一个字段。
- 404 会被客户端翻译成“宿主端还没加载新版本,重启一次应用即可”。
安装 / 卸载
从仓库克隆后按本地目录安装:
git clone https://github.com/BlakrPander/dsh-side-chat.git
然后在 DSH 的插件管理器里把 install_bundle 的 target 指向克隆下来的目录。
本地目录安装(本插件就是这样装的):
# 在 DSH 里用插件管理器安装(推荐)
# plugin_manager: install_bundle → target = 本目录绝对路径
等价的手工方式(会写 profile 的 package.json / patch 层,通常不必自己来):
dsh plugin --profile desktop add link:/path/to/side-chat
启用 / 停用 / 移除:
plugin_manager: set_bundle target=dsh-side-chat enabled=false # 停用
plugin_manager: remove_bundle target=dsh-side-chat # 移除
安装为一个 link 目录时,改完 client.js 刷新页面即可;改完 index.js 由 Host HMR 监听 link 目标自动替换。
开发与自测
node --check client.js # 语法
node --check index.js
# 无头冒烟:选区浮层 → POST → 资源流 → 预填 → 释放。零依赖,只用 Node。
node tools/side-chat-check.mjs
# 在真实 Cordis 上跑一遍 apply(),并模拟平台的两条槽位硬规则。
# 需要一个已解包的 @deepseek-ai/cordis/lib/index.js(DSH 应用包内就有)。
CORDIS_PATH=/path/to/node_modules/@deepseek-ai/cordis/lib/index.js \
node tools/side-chat-apply.mjs declared
tools/side-chat-apply.mjs 会模拟平台的两条硬规则:keyed 槽位同 key 同 priority 注册必须抛错、同名子槽位不可二次声明 —— 这两条正是早期两次“插件 failed”的根因,用它们守住回归。
已知限制
- 会出现在左侧列表(未归组)。这是当前平台下“隐藏”与“能对话”互斥的结果,详见上面的推导。可以进一步做成“开页签时取消归档、关页签时归档”,只在用的时候出现。
- 没有真正的销毁。平台没有删除会话的 API(
sessionPersistence只有 create/open/flush/stat/list),归档又会拦住 turn,所以临时会话只是“不在列表里/不再引用”,日志仍留在磁盘。靠复用把数量压到一条会话一条。 - 拖拽无法区分主会话与侧栏。
ui-attachment把拖拽监听挂在document上、每个 composer 各自按canAcceptDrop接收,没有指针命中测试,也没有“落点高亮”。要做得像 Codex 那样,得改那个宿主插件。 - 子会话权限走部署默认,不跟随父会话当前的 permission profile(审批仍走正常审批服务,绕不过)。
- 遮蔽了原生引用胶囊:接管
quote槽位是为了放第二个按钮,引用行为逐字保留;停用本插件即恢复原样。 - 页签标题固定,不跟随会话标题;一条父会话只对应一个侧栏会话(不区分多次
/side)。
安全边界
- Host 半只暴露一条精确路由,仅 owner Principal 可用,4 KiB 上限,只接受一个
parentSessionId。 - 能做的事等价于“在 UI 里点新会话”:在父会话的
cwd下、用部署默认模型建一个会话。无凭据读写、无文件系统访问、无命令执行。 - 不修改任何宿主配置、不写 profile 的
package.json或cordis.patch.yml。 - 客户端只在
localStorage里存一份父 → 子的会话 id 映射(side-chat.v3.side-sessions),不含任何内容。
版本演进与踩过的坑
| 版本 | 做法 | 结果 | 教训 |
|---|---|---|---|
| v1 | 在 conversation.chat.text-interaction 注册自己的 key |
插件 failed,启动即崩 |
keyed 槽位同 key 同 priority 注册会抛错;要遮蔽必须换 priority(数字最低者渲染) |
| v1 | 页签体额外声明 sidebar.chat.conversation 子槽位 |
抛“slot is already declared” | 子槽位名全局唯一,不能二次声明 |
| v1 | fork + 立即 archiveSession |
侧栏能开、能看,但发消息“已完成,1 秒”无回复 | 归档是 agent/pre-step 的硬闸门,turn 以 blocked 结束 |
| v2 | Host 造 origin:'subagent' 会话(隐藏) |
侧栏能开、界面空白,但输入框只读、附件被拒、消息发不出 | 平台的“隐藏”绑死了“委派”:SUBAGENT_FILE_UNSUPPORTED、session/agent-busy |
| v3 | 普通空白会话 + 会话引用承载上下文 | 可用,落在“未归组” | 可用优先;隐藏留给平台解决 |
其中 v1 的两个槽位错误是靠读 SlotCore.register() 的校验代码定位的 —— 报错只有 dsh-side-chat: failed 一行,web boot 审计不打印原因,插件的 report() 会把真实栈写进渲染器控制台,桌面端崩溃日志才收得到。
许可
MIT。
No comments yet. Be the first to write one.