dsh-side-assistant
English · 中文 · 仓库 · 更新日志 · AGPL-3.0
给当前会话一键开一个常驻侧边助手:它知道这个会话此前聊了什么(以一段压缩摘要的形式,不进它的聊天记录),
可以反复追问,不会打断主任务;
面板顶部是一条实时进展条,默认只占一行——主 Agent 在跑什么、想到了什么,最新一句摘要直接看得见,展开能回看最近几步的思维链。
主 Agent 之后每跑完一步,侧助手都会收到一块进展(这一步的想法、调了什么工具、得出了什么)——所以它不再只知道创建那一刻的历史:
你问「它现在在干什么」,侧助手答的是最新一块。块走的是系统提示,不进聊天记录(见「进展条」一节)。
按钮长在会话标题栏右侧。按一下,右侧栏里就出现一个独立的对话面板:
它知道你这个会话之前聊过什么、干过什么,之后你可以一直问它,主 Agent 继续跑自己的。
已经有侧助手时,按一下会先弹出选择菜单:助手 1 / 助手 2 / … / 新建助手——
可以换一个重开,也可以再开一个(各自独立,各自拿到同一份历史摘要)。
侧助手不是子智能体:它不出现在子智能体目录、不参与子智能体计数、不占 continuable 名额,
也从会话列表里缺席。它对「子智能体系统」唯一可见的痕迹是会话头上的一行血统信息(见「工作原理」)。
它做什么
| 需求 | 落点 |
|---|
| 侧边对话面板 + 输入框 + 消息流 | 由本插件自己的标签页承载,聊天本体直接选 DSH 的 conversation.content 工厂(embedded 变体) |
| 看主 Agent 的实时进展(工具调用 + 思维链) | 面板顶部自绘进展条,session.follow 直连主会话的事件流与 assistant stream |
| 几十上百步也能一眼概览、随时下钻 | 提示词里是每步一行的索引(工具 + 目标 + 成败 + 结论),最新的几步保留原文,更早的用侧助手专属工具 side_assistant_read_steps 现取 |
| 侧助手也知道之后发生了什么 | 主 Agent 每完成一个 step,宿主把这一步折成一块写进侧助手的系统提示片段(side-assistant:progress),不进聊天记录 |
| 知道主会话已完成的历史 | 建会话时把主会话已完成回合压成一段摘要,作为系统提示片段 side-assistant:history(order 120)——它自己的日志从开场白开始,侧栏里看不到主对话 |
| 可反复追问(持久化侧助手) | 会话自带 subagent/descriptor,每次提问按需冷恢复,记录一直留着 |
| 看得见主会话的后台任务 | job 注册表按会话围栏,侧助手自己的 job_list 只看得见自己的任务,所以宿主把主会话的 job 搬进提示片段 side-assistant:jobs(order 122),再配一个 side_assistant_read_job 取输出尾部 |
| 一个都不占子智能体名额 | 用 ctx.agents.create() 建游离会话,不追加 subagent/catalog 事实 |
| 关掉再开不丢 | 侧栏按会话把打开的标签页存进 localStorage |
| 一个会话多个侧助手、换一个重开 | 按一下弹菜单:已有的按创建顺序编号,末尾另有「新建助手」 |
| 回答下面两颗快捷卡片 | conversation.input.dock 里挂两颗圆角矩形卡片:这个项目是在干什么? / agent 正在进行什么工作?。点一下等于自己打出来发出去(conversation.send,和输入框同一条路) |
| 模型、上下文预算、回答风格可自定义 | 设置 → 侧边助手(和「通用」「模型」「插件市场」并列的独立一栏):模型三种模式(跟随主会话 / 指定宿主模型 / 自定义 OpenAI 兼容端点)、API Key 走 credentials、真的发一次请求的「测试连接」、完整 step 条数、token 预算、简约/详细(见「设置:模型、上下文、回答风格」) |
它不做什么(有意为之)
- 不重新实现聊天界面。 消息流、Markdown、工具调用、流式输出、Stop、向上翻页都来自
@deepseek-ai/dsh-client-ui-conversation 的 conversation.content 工厂(embedded 变体);
本插件只加自己的标签页类型、资源提供方和那条进展条。自己画一套只会更差。
相位固定传 active:游离会话在客户端没有可判的元数据,交给工厂自己判会停在空壳上。
- 不把自己登记成子智能体。 不走
ctx.subagents.startContinuable(),不往父会话追加
subagent/catalog;parentSession 只是血统字段(工作区分组 + 侧栏寻址),不代表运行时父子关系
(创建时不传 parentAgent)。子智能体目录、计数、会话树、list_agents 全部只读那份目录事实。
- 不轮询主会话。 进展条用的是框架自己的推送:
session.follow(持久事件 + assistantStream 增量),
没有自建 /status、没有定时器。
- 不往侧助手的聊天记录里塞“进展”。 块走系统提示片段,不走
user / assistant 消息:
聊天记录里只有你和它的对话,翻历史时不会被一堆系统播报淹掉。
- 不往会话流里塞插件私有事件。 唯二写进日志的是框架自己的两个协议事件:
subagent/descriptor(这个会话是什么)与创建窗口内的沙箱/审批策略覆盖(与旧版委派路径一致)。
安装
profile 是 web 的话,直接从仓库安装:
dsh plugin --profile web add https://github.com/TPSCE/dsh-side-assistant.git
不想让 pnpm 走 git clone(网络慢、或者本机没装 git)就用 tarball,一次 HTTPS 下载:
dsh plugin --profile web add https://github.com/TPSCE/dsh-side-assistant/archive/refs/heads/main.tar.gz
装完刷新页面;如果按钮没出现,重启 dsh web(Host 半区的新路由需要重新加载进程)。
改本地代码时可以改装这个目录(装的是同一份代码,只是绕过网络)。路径含空格,务必加引号:
dsh plugin --profile web add "~/code/dsh-side-assistant"
也可以从 Web 界面的插件页安装:粘贴仓库地址,或选这个本地目录。
卸载:
dsh plugin --profile web remove dsh-side-assistant
使用
- 打开任意会话。
- 点标题栏右侧的 侧边助手。
- 一个侧助手都还没有:直接创建(读取历史摘要、发一句开场白),随即在右侧栏打开。
- 已经有侧助手:先弹出菜单,列出 助手 1 / 助手 2 / …(按创建顺序)和 新建助手。
选其中一个 = 把那个重开到右侧栏(各自保留自己的对话记录);选「新建助手」= 再开一个。
- 收起菜单:菜单外点一下、按
Esc、或者再点一次按钮。收起菜单不会创建任何东西。
- 面板顶部是一条进展条,默认折叠成一行(一个小圆点 + 「主 Agent 进展」+ 最新一句思考):
展开后是主 Agent 的工具调用、工具结果,以及每个 step 各自的思维链——正在写的标亮,跑完的变灰留着,
所以一轮跑完再点开,最近几步想过什么还在。中途打开面板也会从当前 step 的开头补齐。
折叠只是显示——它照样把主会话的流订阅着,圆点该闪还是闪;展开/折叠的选择在本次页面里一直记着。
- 在下方输入框里正常提问:它起手一行高,写满后像主对话一样逐行长高。可以一直问,也可以按 Stop 打断正在生成的回答。
- 不要了:再点一次按钮,在菜单里那个助手右边点 删除,再点一次 确认。它从菜单里消失,标签页随即关闭
(删掉的是「显示」——会话日志留在盘上,可从归档列表恢复,见「已知边界」第 6 条)。
- 关闭面板不销毁会话,再点按钮就回来了。
界面:侧栏只有一个输入框
复用自带的对话视图意味着连主对话的输入区一起继承——所有插件往输入区塞的东西都会出现在侧栏里。
client.js 里的 SIDEBAR_CSS 把侧栏面板裁回一个朴素的输入框,只在这个面板内生效:
| 裁掉 | 谁带来的 |
|---|
conversation.composer.dock(N 轮 · M 步 · tok/s · 缓存命中) | dsh-client-ui-chat 的 stats 统计条、dsh-cost-meter |
conversation.input.dock(未提交的更改:N 个文件、额度条) | dsh-client-ui-git-graph、dsh-cost-meter、@openmaic/dsh-openmaic |
conversation.input.attachments(附件栏 / 拖放区) | dsh-client-ui-attachment |
conversation.input.left / .right(余额、插件杂项按钮) | deepseek-harness-wallet、@linxin666/dsh-web-all 等 |
conversation.input.activity(横跨工具行的活动指示) | 对话外壳 |
输入区左侧整行(+ 上传/命令菜单 + 计划 + 权限选择) | 对话外壳 |
留下的是:消息流、输入框、模型选择、发送/Stop,以及每条回复下的复制 / 用量 / 时间。
想调整就改 client.js 顶部的 TRIMMED_SLOTS 数组(每个槽一行,删掉哪行哪样东西回来);
只想藏 + 而保留「计划 / 权限」两个座位,就删掉 SIDEBAR_CSS 里最后那条
div:has(> input[type="file"]) 规则。
裁剪规则选的是 [data-sidebar-chat]。这个属性以前由自带的 subagent 标签页打上,
现在由本插件自己的面板根节点打上(chatTab 的根 div),所以规则依然只在侧栏面板内生效。
消息动作行
助手回复下面那排按钮不在槽里,是聊天视图自己画的固定条:
复制 → conversation.chat.assistant-actions 座位 → 分支 → 用量 + 时间。只留复制、用量、时间:
| 裁掉 | 怎么认得它 |
|---|
点赞 / 踩(dsh-client-ui-message-feedback)、在上下文浏览器中查看(dsh-context) | 两者都注册进 conversation.chat.assistant-actions 座位,藏掉座位一次全消 |
| 分支按钮 | 助手尾部这一行的最后一个直接子元素按钮 |
分支那条规则带了 [data-actions-reveal] 限定:这个属性只有助手轮次尾部会写,
而用户气泡那一排的最后一个按钮正是复制——不限定就会顺手把用户消息的复制也藏掉。
用量按钮在 span.endInfo 里,不是这一行的直接子元素,所以 > button:last-of-type 够不到它。
输入框:起手一行,之后照抄主对话
侧栏输入框起手只有一行高(24px,正好是自带输入区的行高),主对话那份是 36px——没写满第一行时一眼分得开。
写满之后它和主对话完全一样:每多一行长 24px,长到自带的上限(336px,约 14 行)才开始在框内滚动。
只动了两处,都是「下限」而不是「上限」:
| 规则 | 作用 |
|---|
[data-composer-input] 的 min-height: 24px + padding-top: 0 | 一行的行高就是 24px,所以这就是「一行」的起手高度;之后每换一行自然 +24 |
[data-composer-placeholder] 的 inset 上边归零 | 提示语是绝对定位的,跟着编辑区的新内边距走,否则会飘到文字下面 |
没有设 max-height、没有给编辑区挂 overflow、也没有重声明 --dsh-composer-text-max-height——
这三样任何一个都会把「第二行」变成滚动而不是长高:光标在第二行,滚动跟着光标走,第一行就被挤出那个没变高的盒子,
看起来像「第一行消失了,第二行顶到第一行的位置」。这个坑 0.9.1 踩过一次,测试里现在反过来钉死这三条不许出现。
卡片的上内边距与间距顺手收窄(8→6、12→6);下面那排按钮(模型选择、发送 / Stop)不动——它们不是本插件的东西。
想完全照主对话(36px 起手),删掉 min-height 与 inset 这两条即可。
运行中只有一个按钮
主对话在跑的时候,那颗主按钮会变成 Stop。侧栏用的是子会话,自带输入区不做这个替换
(primaryStops 要求 subagent === null),而是在 Send 旁边额外长出一颗 Stop
(interruptible = running && continuable)。消息一提交草稿就清空,Send 便停在禁用态——
于是侧栏会出现「Stop + 一颗灰色的 Send」,看着像坏了一颗。
SIDEBAR_CSS 只在这一种状态下藏掉后面那颗:行尾容器里出现两颗相邻按钮、且最后一颗是 [disabled] 时才隐藏;
一旦你开始打字、Send 重新可用,规则立刻失效,按钮自己回来。判据只用属性选择器与行结构,不依赖构建期哈希类名;
自带 DOM 若改成嵌套结构,这条只是不再匹配,不会误藏别的按钮。
输入框提示语
提示语是真实渲染出来的文本节点([data-composer-placeholder],与可编辑区同级),CSS 换不掉,
所以做法是:把原文字号压成 0 藏起来,再用 ::before 画上自己的。
ask anything~
只有输入框可用时才替换。不可用 / 离线 / 被拦截时,原提示语是状态而不是装饰,
那几条规则会把它放回来——判据是同一父节点里可编辑区的 aria-disabled="true"。
无障碍名称仍来自原本的 aria-label,读屏听到的还是官方那句(含 /、@ 的提示)。
为什么用 CSS 而不是接管 conversation.composer 链。 那条链是官方的「替换本会话输入区」扩展点,
但它的 select 只拿到 Session:一旦接管,子会话在主视图里被打开时输入区也会跟着变;
而且接管会把常驻输入区(它持有待处理的审批交互和 Stop 按钮)藏起来。CSS 只动观感,不动功能。
为什么不在 locale 里改提示语。 那会连主对话的输入框一起改,而主对话正需要
/ 指令和 @ 文件的提示;侧栏要的是「这是聊天」的口气,不是全局换文案。
开场消息:行为不走对话框
新建侧助手时 Host 会投递一条 hello。它不是提示词,只是点火:DSH 用首条消息打开会话的第一轮。
侧助手的行为走的是会话的 agent 组合,不经过对话框:
| 行为载体 | 落在哪 |
|---|
persona | 这个会话的 scoped deployment:persona-prefix 段,只覆盖它自己 |
denyTools | 这个会话的 scoped tools.restrict():工具既从提示里消失,也不可调用 |
reasoningEffort | 创建时的 agentOptions,并随 subagent/descriptor 持久化 |
这样面板读起来就是「一个助手,不是用户塞了一段提示词」。代价是那句 hello 仍是一条真实的
user 消息,所以它会在面板里显示成一个用户气泡——见「已知边界」第 11 条。
不想要它就配 opener: "":会话会开在一个空对话上,等你先说话。
面板不等这句开场白。 /side-assistant/ensure 在会话建出来、问候投递出去之后就应答,
问候那一轮在后台跑完才落盘、释放 agent。所以「按下按钮 → 面板出现」之间没有模型往返:
面板先开,回复随后流进来,这期间你已经可以输入(消息排在问候之后)。
点开之前就已经准备好了
侧边助手不是「点一下才开始做」——面板里那个按钮出现在会话标题栏之后,页面会先安静半秒,
然后向宿主请求把这个会话的侧助手提前建好(POST /side-assistant/preload)。
点开时只剩一次查表:会话已经在列表里,面板直接进对话。
它挑的是便宜的做法:
- 已经有侧助手就复用,不重建——所以反复切换、反复重开都不会越建越多。
- 只在你真的在的会话里做。 请求里只有一个
sessionId,宿主先看这个会话有没有驻留 agent;
没有就跳过(skipped: "parent-not-live")——那种会话本来就建不了侧助手,也不该建。
- 半秒防抖。 只是路过一个会话不该留下一个助手。
- 开场白照样在后台跑,不等它;面板开的时候你可能已经能打字了。
代价是实打实的:每个你停留过的会话,提前建好一个侧助手(一份会话日志 + 一轮问候的模型调用)。
不想要这笔开销就配 preload: false,回到「点了才建」。
进展条:主 Agent 在想什么
面板顶部那条是只读的观察窗,数据全部来自框架自己的会话流:
| 看到什么 | 从哪来 |
|---|
工具名 + 参数摘要、工具结果(错误标红) | 主会话的 tool/call / tool/result 持久事件 |
| 每个 step 的思维链(流着的是 live,跑完的标灰保留) | assistantStream 的 reasoning-delta,以及 assistant/message 里的 stream |
| 本回合结束 | turn/start / turn/end |
小圆点状态:待命 / 进行中 / 读不到 | 流是否活着 |
思维链是留下来的,不是闪一下就没。 主 Agent 每个 step 的思考单独成块:正在写的那块标 live(左边框高亮),
这个 step 结束就转成 done(变灰保留),下一个 step 不会把它冲掉——所以跑完一轮再点开,最近几步想过什么还在。
两块补看是自动的:
- 面板是中途打开的:
session.follow 的快照帧带 assistantStream.activeAttempt,也就是"这个 attempt 到现在
已经流出了什么"的压缩前缀。打开时把它展开、按 nextIndex 截到已发布的部分,从 step 开头补齐,之后接增量——
不是你打开那一刻才开始记。
- 面板打开时某步已经结束:该步的
assistant/message 持久事件里带着整段 stream,从里面还原 reasoning。
快照里的历史事件也走这条路,所以刚打开面板就能看到之前几步的思考。
上限:思维链最多留 6 块(每块 4000 字,超了从前面截断,保留结论),工具行 30 行;块与行各有各的预算,
工具多的一轮不会把思考挤出去。展开时自动跟到底部,但你手动往上滚之后就停止自动跟随,不会跟你抢滚动条。
默认折叠。 展开态的子任务行 + 思维链有几十行高,而它们和你在主界面看到的是同一份东西:
用户在主视图里看着主 Agent 跑,侧栏再重复一遍只是把聊天的位置挤掉。所以面板打开时它只有一行——
一个状态圆点、主 Agent 进展、最新一句思考的摘要(折叠也能看见它在想什么)、一个折叠箭头,
右端还有一个折叠箭头——需要时点开,选择在本次页面生命周期内记住(client.js 的 progressOpen:换会话、重新挂载都不丢,刷新页面回到折叠)。
折叠的是显示,不是订阅:session.follow 照样连着,圆点该闪还是闪,读不到时那一行也会显示
「进展读不到,看看控制台」,所以折叠状态下也不会漏掉异常。
实现是 ctx.remote.session.follow({ "address": { kind: "session", sessionId: 主会话 }, assistantStream: true }):
一次订阅拿到快照(已经发生过的事件 + 正在跑的 attempt 的压缩前缀)+ 之后的每条增量,面板关掉即断开。
压缩流记录的展开(reasoning-chunks → 逐条 delta)在插件里本地实现了一份,因为框架的那个 helper
不在浏览器半区可 require 的面里。
这些块也会送到侧助手那里,但走系统提示,不走聊天记录。 主 Agent 每完成一个 step,宿主就把这一步折成一块
(结论 + 工具调用 + 想法),挂进侧助手会话的系统提示片段 side-assistant:progress(order 125,紧跟委派声明)。
每块第一行是一句话结论:取这一步自己收尾文字的第一句;只动了工具、没有文字时,结论就是「Conclusion: this step only used tools (read, bash) …」
这样的动作概述。后面才是工具动作,想法原文放在最后——先给结论,再给细节,所以问「现在在干什么」时,
它答的是一句话,而不是一段思维链。手上同时有几块时,按由早到晚的顺序逐块讲(每块一句话),最后落在最新一块,
让人看到的是「它先做了什么、接着做了什么、现在到哪了」,而不是一团并行的碎块。问更早的步骤,它按索引答、
需要细节就用下面那个下钻工具查,并会说清自己的副本覆盖到哪一步——而不是一句「我只收到最近几步」。
块不进日志,面板里仍然只有你和它之间的对话,历史摘要也仍然停在创建那一刻(见「已知边界」第 1 条)。
上限与代价:索引行 + 下钻工具。 每步的「一行」不再是一句散文,而是一条索引:
[step 12] bash:node test/host.smoke.mjs✗, edit:client.js — 加了删除按钮
跑了什么工具、动了哪个目标、成功还是失败、结论是什么,大约 60–90 字符。于是三层各管一段:
| 层 | 覆盖 | 上限 |
|---|
| 原文块 | 最新的几步,想法 + 工具 + 结论 | 5 步(设置可改 1..50) |
| 索引行 | 之前每一步 | 200 行,字数额度跟随「上下文预算上限」(默认 6000 字 ≈ 七十步) |
| 下钻 | 宿主还留着的最近 400 步 | 一次最多 40 步 / 8000 字 |
下钻是侧助手自己的工具 side_assistant_read_steps({from, to, query}):问「第 12 步改了什么文件」、
「哪一步碰过 client.js」时它自己去取原文,而不是让宿主猜该塞多少进提示词。它注册在侧助手的工具作用域里
(agent.ctx 的 tools.register),主 Agent 与真正的子智能体都看不见;冷恢复时由 agent/created 重新挂上,
每次最多返回 40 步并说明自己实际读到第几到第几步,越界时也说清楚「只留到第 N 步」。
一份完整注入通常一两千字——刻意的:注入太长会挤掉你真正想聊的内容。块一变,提示词缓存就失效一次——
实时进展比缓存命中重要。片段文本是 getter,每次拼提示词时重算,所以用户提问之后、模型开口之前刚落下的那一步也在里面。
后台任务:job 与 shell 进程
主 Agent 的每一步里,后台命令只留下一句「启动了 bash-3」;它跑得怎么样,在 job 注册表里。侧助手拿不到那份数据——job_list 是按会话围栏的,它自己的调用只看得见自己的任务。所以宿主把主会话的任务搬过来,挂在系统提示片段 side-assistant:jobs(order 122,在历史摘要之后、进展块之前):
bash-3 running npm test 2/5 2m ago
bash-2 completed pnpm build exit code: 0 took 34s
- 列的是正在跑的和最近十分钟内结束的,最多 8 条,一行一条:id、状态、命令(截到 70 字)、进度行、用时或结局。
- 要看输出就调第二个工具
side_assistant_read_job({job}),返回尾部最多 4000 字符(被裁会说清「这是尾部」)。它读的是保留输出窗口的绝对偏移(readAt),不动主 Agent 自己的读取游标——否则就把它该看的那段吃掉了。
- 两个工具都注册在侧助手自己的工具层(
agent.ctx),主 Agent 和真正的子智能体都看不见;job 注册表不存在时这段提示与这个工具都不出现,面板照常工作。
边界:侧栏里的交互式终端不属于 job 注册表,宿主也没有列举它们的服务,所以这里看不到;
子智能体 job 会以 subagent-N 出现,但它们的产出去子会话日志,不在输出环里,尾部读起来是空的。
删除助手:在菜单里,先归档再关标签页
删除按钮跟着它要删的东西走:再点一次头部按钮打开菜单,每个「助手 N」右边就是一颗 删除——两个字,
吃的是菜单行的宽度,不占进展条,也不用记「右端那颗按钮删的是谁」。文案跟随系统语言(zh 是 删除,
en 是 Delete)。
两段式:按下变成 确认,再按一下才动手;四秒内不按第二下就自己放弃(一次误点不会在半小时后被「补完」)。
删除走的是框架的工作区归档(workspaces.archiveSession,带 stopActivity):
- 从菜单里消失。 宿主列菜单时把归档集合一起减掉,所以删掉的不会被
ensure({}) 重新打开,
下次点按钮是新建一个;剩下的行重新编号(助手 1、助手 2……)。
- 标签页关闭。 菜单只知道子会话 id,不知道它开在哪个标签页上——标签页 id 是停靠区发的。
所以面板自己在挂载时把这对关系记进
client.js 的 openTabs,卸载时忘掉;删除时查这张表,
当前打开的正是它就把标签页关掉(否则留下的只会是个空壳)。
- 日志还在。 归档不是擦除:会话记录留在盘上,可以从工作区的归档列表恢复(恢复之后菜单里又会出现它,
因为它重新变回「游离血统 + 当前组合 + 未编目」)。
- 失败会说话。 归档失败时那一行变成
删除失败,行和标签页都不动,再点一次即可重试(细节在控制台)。
刻意没做的:没有批量删除、没有二次弹窗(确认写在按钮自己身上,省一次点击)、也没有用它替换
sidebar.right.tab.menu.item 槽——那个槽只在标签页自己的下拉菜单里出现,而这个面板的标签页头在折叠栏里,
藏得太深;菜单里那一行是用户本来就要打开的地方。
回答下面的快捷卡片
最后一条回答下面、输入框上面,有两颗圆角矩形卡片:
- 这个项目是在干什么?
- agent 正在进行什么工作?
点一下就把这句话发出去——走的是 conversation.send(),和用户在输入框里按发送是同一条路径,
所以会出现一条正常的用户气泡,接着是一轮正常的回答(正在跑的时候进队列,等这一轮结束再发)。
它们挂在会话自己的 conversation.input.dock 槽位上(队列条、todo 条也在这里),所以位置天然是「回答下面、输入框上面」。
槽位本身是全会话共享的:同样的入口会出现在每一个对话视图里,所以两条 CSS 规则把默认态设成 display: none,
只有祖先带 [data-sidebar-chat](也就是本插件的面板)时才 display: flex —— 主对话那边一点痕迹都没有。
这个槽位原来整块被输入区精简规则藏掉了(它同一条里还带着 git 分支行、额度条、队列条)。0.11.2 起改成
只藏别人的子节点:[data-sidebar-chat] [data-slot="conversation.input.dock"] > *:not(:has(.dsh-sa-cards)),
卡片自己是这个锚点的直接子节点,所以照常显示,其余条目(git 行、额度、队列、todo)仍不出现在面板里。
设置:模型、上下文、回答风格
设置 → 侧边助手(0.15.2 起;0.12.0–0.15.1 它藏在「设置 → 插件」的标签页里):设置对话框左侧导航里独立的一栏,和「通用」「模型」「插件市场」并列,不点进「插件」也能找到。保存后不用重启,新开的助手按新设置工作。
| 设置项 | 默认 | 说明 |
|---|
| 模型 | 跟随主会话 | 跟随主会话=和主 Agent 同一个 provider/model;指定宿主模型=填 provider + model(先在 设置 → 模型 里配好);自定义端点=填 Base URL + 模型名 + API Key,宿主把它写成 llm-pi-ai 的 providers.side-assistant-custom,于是自定义端点和内置 provider 共用同一套重试、凭据解析和模型列表 |
| API Key | — | 存进 credentials 服务(引用名默认 SIDE_ASSISTANT_API_KEY),不进设置文件、不回显;页面只显示「已配置」 |
| 测试连接 | — | 真的发一次极小请求(maxTokens ≤ 16),回报耗时和实际用的 provider/model。配置能解析不等于能用,只有打到网关上才算通过 |
| 进度保留的完整 step 条数 | 5 | 1..50;比这更旧的 step 折成一行索引(工具 + 目标 + 成败 + 结论) |
| 上下文预算上限(token) | 2000 | 500..200000;1 token ≈ 索引 3 个字符 / 历史摘要 2 个字符 |
| 回答风格 | 简约 | 简约=一到两句;详细=讲清每一步做了什么、为什么、动了哪些文件 |
| 助手名 / 开场白 | 侧边助手 / hello | 只影响新开的助手 |
存在 ~/.dsh/storages/side-assistant/settings.json(根目录可用 DSH_HOME 改):一个小 JSON 文件,
读坏了就逐字段回落默认值,不会让面板起不来。
几个刻意的边界:
- 没有
Config schema:设置是插件自己管的文件,不走 DSH 的设置命名空间。好处是保存即生效、不用重启、不受 schema 校验束缚;
代价是这些键不出现在 dsh config 之类的命令行视图里。
- 行配置仍是基线:
cordis.patch.yml 里的 label/opener 是默认值,页面上保存的值优先。
- 风格是活的,其他是创建时定的:回答风格挂在会话的 prompt context 上(
side-assistant:style,order 118),
改完下一个 agent/created 就生效;助手名和开场白写进描述符,只影响新建的助手;step 条数只影响之后的折叠
(已经折叠成索引的历史不会因为调大上限而重新展开)。
- 自定义端点的 key 走 credentials:页面永远读不回来,留空保存=不动已有的 key。
配置
cordis.patch.yml 里的 config 会原样传给 apply(ctx, config),每个键都可省略:
| 键 | 默认 | 说明 |
|---|
label | 侧边助手 | 菜单里的标签,同时写进会话的描述符(不再是识别键,见「工作原理」) |
opener | hello | 打开新侧助手第一轮的那条消息。只用来开场,别放指令——DSH 把它当普通用户消息投递,指令写在这里就等于「用户自己把提示词敲进了输入框」。给空字符串 = 不打招呼 |
persona | 侧助手行为规则(英文,规则里写明用用户的语言回答) | 侧助手的行为在这里:作为 scoped persona 前缀注入会话;不要写 {{…}},会被当模板插值 |
denyTools | [send_message] | 从会话组合里删掉的工具。删掉的工具既不在它的提示里也不可调用。给空数组 = 不限制 |
reasoningEffort | off | 侧助手的思考档位。默认关掉(侧问是你在等的,不开思考模式);填 low / high / max 打开,填 inherit = 完全不传,继承主会话的路由 |
preload | true | 你在某个会话里停留半秒后就提前把它的侧助手建好,把「复制历史」那一步挪到点开之前(见「点开之前就已经准备好了」)。设 false 关掉 |
例:
- insert:
- id: dsh-side-assistant
name: dsh-side-assistant
config:
label: 我的助手
reasoningEffort: low # 想让它带思考,就这样打开
denyTools: [send_message, shell]
工作原理
两半区,一个入口。
Host index.js 浏览器 client.js
POST /side-assistant/list 标题栏按钮(order -15)
├─ sessionQuery.listSessions() 按一下 → POST /list
│ 筛 parentSession 是本会话 + origin=subagent ├─ 空列表 → POST /ensure {sessionId}
└─ 减去 subagents.listChildren()(真正的委派) └─ 非空 → 弹菜单
⇒ 剩下的才是游离侧助手 助手 i → /ensure {sessionId, childId}
按创建时间从旧到新 新建助手 → /ensure {sessionId, create:true}
POST /side-assistant/ensure ↓
├─ { sessionId } 当前那个 └─ create 时: sidebarRight.openResource(
├─ { childId } 指定重开 agents.create({ dsh-resource://sideassistant/chat/
├─ { create:true } 一定新建 没有 parentAgent, <child>?parent=<parent>)
└─ 其余:复用最新那个 meta.parentSession, ↓
→ { childId, created } history: 历史摘要 自己的标签页 = 进展条
setup: 描述符 + 组合 + 自带对话视图
})
- 创建必须走 Host。 Client 的
subagents 远程命名空间只暴露 prompt 与 interruptByParent,没有创建。
agents.create() 不传 parentAgent ⇒ 运行时它是个 root agent。 这不是偷懒,是需求:
子智能体的身份来自父会话日志里的 subagent/catalog 事实(listChildren / 计数 / 会话树 /
list_agents 全读它)。不追加那条事实,就没有任何地方会把它算成子智能体。
- 为什么还留着
origin: 'subagent' + parentSession。 侧栏聊天要能寻址和驱动这个会话:
地址校验要求 origin === 'subagent' 且 parentSession 与地址里的一致,
并要求这个会话自己的日志里有 subagent/descriptor;送消息时的冷恢复也只读那份描述符
(coldResume():mode 必须是 continuable,其余从描述符重建)。
也就是说:这两个字段是给侧栏用的接口,不是给子智能体系统用的登记。
setup 是创建窗口。 agents.create() 的 setup(childCtx, child) 在会话与 agent 被插入、发布之前跑,
框架自己也是在这里 append 描述符的。这里做四件事:写 subagent/descriptor(v3:version / mode / provider / label / agentProvider / agentModel / agentReasoningEffort / persona / toolFilter,未知键会抛)、
继承沙箱与审批策略(和旧版委派路径一样把审批钉成 never)、
agentPresets.composeFrom() 加入父会话的组合、scoped persona 段 + tools.restrict()。
- 收尾:先落盘,再
dispose()。 AgentHandle.dispose() 只把会话从运行时注销并停掉循环,
不动它的日志;所以创建完 sessions.flush() 再 dispose,之后每次提问都走冷恢复。
(卸载插件时不能 dispose:那会连会话一起删掉。)
- 发现靠「相减」。
sessionQuery.listSessions() 里凡是 parentSession 指向本会话且
origin === 'subagent' 的,减去 subagents.listChildren() 里有的——剩下的就是游离侧助手;
再减去工作区归档集合(workspaceRegistry.archivedSessionIds)里有的,删掉的才不会回来。
所以 label 不再是识别键(0.6 之前靠它,改名等于换一个;现在改名只改显示)。
另有一道时间闸 COMPOSITION_SINCE(index.js 里的常量),比它早建的一律不算——
包括 0.7 之前那版真子智能体侧助手,它们大多也在目录里,两道闸都拦得住。
- 名额。 不再占 continuable 名额(那是激活表的事,激活表只由委派路径填充)。
唯一还会碰到上限的是提问那一轮:冷恢复要临时占一个激活位(框架的
maxActiveSubagents,默认 8),
占满时报错由框架给出(subagent limit reached …),面板里会看到那条错误。
- 地址是契约,不是字符串拼接。 本插件有自己的协议与标签页类型
(
sideassistant / dsh-resource://sideassistant/chat/<child>?parent=<parent>),
与自带 subagentchat 语法互斥:两个语法解析对方地址都必须失败,谁也不会抢走谁的标签页。
会把这条契约连同 glob、编码、拒收样例一起校验。
已知边界
- 历史是摘要,进展是流。 侧助手不再复制主会话的聊天记录:建会话时把主会话到最后一个
turn/end 为止的内容
压成一段摘要(用户问句 + 主 Agent 的回答,每条截断、整体有字数上限,最近的优先),作为系统提示片段
side-assistant:history 交给它。所以侧栏打开是一段干净的对话,而它仍知道你们聊过什么——但摘要是压缩过的,
它不该假装记得逐字原文。那之后的进展按块推进系统提示(见「进展条」)。
- 主会话里正在跑的那一轮不进摘要。 未闭合的回合没有结论可摘,那一段由进展块负责。
- 没有任何已完成回合的会话也能建侧助手,只是它从零摘要开始(不报错)。
- 一个主会话可以有多个侧助手,它们只靠「血统 + 未编目」识别,不靠
label。
菜单序号是创建顺序;删掉中间某个之后,后面的一起前移一位(删掉的那个不再进列表)。
真正的委派子智能体不会被误认——它们在目录里,第一道闸就筛掉了。
persona / denyTools / reasoningEffort 是创建时定死的。 它们进的是会话的 agent 组合,
事后改不了(描述符虽然落在日志里、理论上可读,但插件不打算依赖它的 schema 稳定)。
改完配置要让旧侧助手失效,把 index.js 里的 COMPOSITION_SINCE 推到新的时间点。
- 删除是归档,不是擦除。 菜单里每行右侧的
删除 调的是工作区的 archiveSession:会话从菜单与工作区列表里消失、
标签页随即关闭,但日志留在盘上,可以从归档列表恢复。它不进会话树,所以没有「父会话里清掉子智能体」那条路——
恢复之后它重新算作「游离血统 + 当前组合 + 未编目」,菜单里又会出现。想彻底销毁日志就得手动删 DSH 会话存储里的记录。
- 依赖自带的对话视图与右侧栏。 聊天本体由
dsh-client-ui-conversation 的
conversation.content 工厂提供(dsh-client-ui-chat 画消息流),标签页由 ui-sidebar-right 提供;
禁用它们之后面板要么打不开,要么只剩下那条进展条。缺失时插件不会崩:
面板服务是用 ctx.inject 单独申请的,聊天区加载失败会显示一行「侧边对话加载失败」而不是空白。
- 不进会话列表。
origin === 'subagent' 的会话本来就被工作区列表过滤掉,这正是我们要的零污染。
- 输入区精简(以及快捷卡片的可见性)靠两个 DOM 契约:面板根节点上的
data-sidebar-chat(现在是本插件自己打的)
和槽位锚点 div[data-slot="<槽名>"]。前者失效只影响裁剪规则的命中范围,
后者是别人给的外部契约(渲染器设置,display:contents,DSH 自己的样式表也按它选元素),
DSH 大版本升级时值得回看一眼;失效的表现是「某些控件又出现了」,不会报错、不会坏功能。
消息动作行与提示语同理,另外还依赖 data-actions-reveal(助手轮次尾部独有)、
data-composer-placeholder 与可编辑区的 aria-disabled——这几个都是 dsh-client-ui-chat /
dsh-client-ui-conversation 的渲染细节,比槽名更容易变。失效的表现分别是「按钮又回来了」和
「提示语又变回官方那句」,两者都不影响功能。
- 提示语只在不可用 / 离线 / 被拦截时才让位。 计划模式、引导排队这两种状态也会被换成
「ask anything~」——它们不是错误态,但也不是这个面板要表达的重点;想让它们也保留原文,
把
SIDEBAR_CSS 里那两条 :has(> [data-placeholder][aria-disabled="true"]) 规则的条件放宽即可。
- 开场那句
hello 会在面板里显示成一个用户气泡。 它是真实的 user 消息(DSH 固定这么投递,
插件改不了角色),而面板渲染的就是这个会话的记录。现在它的日志从这句开场白开始(历史是摘要、不是种子),
「第一个用户气泡」就是它,按位置藏也是安全的;插件仍然默认不藏,不想要开场白就配 opener: ""。
- 思考档位的取值是适配器自有的。 DeepSeek 适配器是
off | low | high | max,插件不校验:
填了该路由不认的值,表现是建会话直接失败并带 ,
这时改成 即可。想中途改已建好的侧助手,就用侧栏里的模型选择器(它带 effort 档位)。
测试
没有测试框架,直接跑:
node test/host.smoke.mjs # Host 半区:路由、幂等、并发合并、创建窗口、进展块、错误分支
node test/client.smoke.mjs # Client 半区:工厂契约、槽/资源/标签页注册、地址、菜单、进展条
node test/address.contract.mjs # 地址契约:自家语法、独立 URL 读法、与自带 subagentchat 互斥
三个都用假 Context、假 DOM、假 fetch,不需要启动 DSH;client.smoke.mjs 里有一个几十行的
hook 运行时(尊重依赖数组,能安装/卸载 effect),能把按钮与面板真的渲染出来、按下去、
再喂几帧 session.follow 的假数据,所以菜单与进展条的每个分支都是行为验证,而不是只断言字符串。
address.contract.mjs 会自动找到本机 DSH 安装并交叉校验两条语法的互斥性,
找不到就只跑本地语法(用 DSH_SUBAGENT_CLIENT=<…>/dsh-client-ui-subagent/lib/client.js 可强制指定)。
许可
Copyright (C) 2026 the dsh-side-assistant authors.
本程序是自由软件:你可以依照 GNU Affero 通用公共许可证第 3 版(AGPL-3.0,由自由软件基金会发布)
重新发布和/或修改它——第 3 版,或者(按你的选择)任何更新版本。
本程序是希望它有用而发布的,但不带任何担保,甚至不带适销性或特定用途适用性的默示担保。
详见 GNU Affero 通用公共许可证。许可证全文见 LICENSE,没拿到就看 https://www.gnu.org/licenses/。
因为是 AGPL-3.0:把这个插件改过之后作为网络服务提供给别人用,有义务向使用者提供对应的源码——
它本来就是作为 DSH 网页界面的一部分运行的,所以这条义务覆盖的是你运行的那份插件代码,不只是分发出去的副本。