dsh-explain-assistant · 解释小助手
一个 DSH(DeepSeek Harness) Web GUI 插件: 在主对话右上角放一个「?」,点开一个独立浮窗,用白话中文告诉你主 agent 正在做什么。
模型半天不吭声、或者吐出一堆你看不懂的东西时,用它盯进度、看有没有走歪; 不懂技术也能把握项目进行到哪;还能让它给你提下一步建议。
它只解释,不动手 —— 不向主 agent 发消息、不改它的上下文、不打断它的任务。
你什么时候会需要它
下面这几种情况,如果你碰上过任意一种,这个插件就是为你做的。
一、模型半天不说话,你不确定它是在干活还是走歪了
主 agent 接了任务会连着跑很久:读文件、执行命令、改代码、再验证。这段时间它可能一连几分钟没有一句人话, 也可能憋出一大段你看不懂的输出。你不知道它是在正常推进、卡在某一步、还是已经跑偏到别的地方去了。
这正是最难受的时刻:既不敢打断它(怕毁掉它正在做的事),又完全不知道进展。
点开小助手问一句「它现在在干什么」,它会告诉你当前这一步在做什么、为什么做、实际产出了什么, 并且明确标出这条结论是已观察到(它真的读到了主 agent 的工具调用与输出)还是仅据汇报(只有主 agent 自己这么说)。 跑偏、卡住、反复重试同一件事,都能从这些步骤记录里看出来。
二、你不太懂技术,但你需要知道这个项目进行到哪了
主 agent 的输出默认是写给会写代码的人看的:一串命令、一屏报错、几个文件名。 如果你是这个项目的需求方、验收方,或者只是想知道「东西做到什么程度了」,这些内容对你几乎没有信息量。
小助手会把每一步翻成白话中文:不写技术文档的口吻,遇到术语当场用一句人话解释 (比如「工具调用(就是让电脑去跑一个具体动作)」)。你不必先学会这套黑话,就能听懂它在干什么、做到哪一步了。
三、你想让它给你提建议,而不是只当解说
小助手回答的第四个要素固定是**「哪里可以调整」**:如果想改,从哪里入手、有什么代价。 所以你可以不只是问「它在干嘛」,还可以问:
- 「这一步有必要吗?有没有更省事的做法?」
- 「按现在这个进度,还有哪些事没做?」
- 「我接下来该让它做什么?」
它只读、不动手,所以这些建议不会自己去改任何东西 —— 决定权始终在你手里。
为什么不能直接去问主 agent
最自然的反应是直接在输入框里问「你现在在干嘛」。但那一问会插进它正在进行的对话: 挤占它的上下文、可能改变它接下来的判断,严重时会让它放下手里的活来回答你。
于是很多人干脆憋着不问,一路看到结束也不知道发生了什么。
这个插件把「提问」搬到旁边去做:
| 主 agent | 解释小助手 | |
|---|---|---|
| 你在哪问 | 主对话输入框 | 右上角「?」弹出的浮窗 |
| 会不会打断 | 会:插进对话、占上下文 | 不会:完全独立的一条请求 |
| 谁回答 | 正在干活的那个模型 | 你自己单独挑的模型 |
| 能干什么 | 执行任务 | 只读:七个只读工具,没有执行、没有写入 |
一句话:它在旁边看着主 agent,随时把"它正在干什么"讲成人话,而主 agent 完全不知道你在问。
适合谁、不适合谁
适合你,如果:
- 你在跑长时间任务,中途想随时知道它进行到哪、有没有跑偏(上面的情况一);
- 你不写代码,但需要把握这个项目的进展(情况二);
- 你想问「这一步有必要吗」「接下来该做什么」,但不想自己动手改(情况三);
- 你想问,又不想打断主 agent、不想让它发现你在问;
- 你希望解释有依据可查,而不是听模型编一个像模像样的答案。
不适合你,如果:
- 你想要的是让 AI 替主 agent 干活、接管任务、自动纠错 —— 本插件只读,做不到,也不打算做;
- 你想要的是自动监控(比如进度停滞就自己弹提醒、自己重试)—— 本插件是你主动点开才看, 不做后台告警、不替你介入;
- 你只是想换个主题或调 UI —— 那是另一个插件(如
dsh-warm-ui)的事; - 你期望它保证省钱。小助手是独立请求,虽然与主 agent 对话隔离, 但同一账户可能共享费用、额度和资源竞争,插件不承诺"绝无资源竞争"。
特点
下面每一条都能在代码里指到落点,不是宣传语。
支撑「盯进度」的
| # | 特点 | 具体表现 | 落点 |
|---|---|---|---|
| 1 | 看得到每一步的成败 | 读的是主 agent 的工具调用与执行结果(不是只看它最后那段总结),所以能回答"哪一步在跑、跑成没有" | src/host/session-context.ts renderMainline;src/host/record.ts fromToolTrace |
| 2 | 依据分级,不把"它说的"当"已证实" | 每条依据标「已观察到」/「仅据汇报」/「无从得知」——主 agent 自称"完成了"但没验证,会落在第二级 | src/host/prompts.ts EVIDENCE_TIERS |
| 3 | 信息不足就明说,不编造 | 拿不到信息时固定写出「该步未提供足够信息」,并说明缺什么,不用空话凑数 | src/host/prompts.ts INSUFFICIENT_MARKER |
| 4 | 记录没完成会写明原因 | 不是笼统的"未完成",而是分别给出超时 / 模型失败 / 触及上限 / 没拿到摘要 / 你主动停止,并给补救建议 | src/shared/record-reason.ts RECORD_REASON_TEXT |
支撑「讲成人话」的
| # | 特点 | 具体表现 | 落点 |
|---|---|---|---|
| 5 | 白话中文,术语当场解释 | 内置 15 条术语对照(工具调用 / 命令 / 终端 / 编译 / 上下文 …),出现时当场用一句话说明,不用行话解释行话 | src/host/prompts.ts TERM_HINTS |
| 6 | 一次解释覆盖四要素 | 在干什么 / 为什么做 / 实际产出 / 哪里可以调整,缺一不可 | src/host/prompts.ts FOUR_ELEMENTS |
| 7 | 模型不可用时仍能看 | 已有问答、你选中的依据、以及一份本地生成的基本说明照常显示,并明确标注"这不是模型给出的解释" | src/client/overlay.tsx offlineFallbackNodes |
支撑「不打扰、可信、可追问」的
| # | 特点 | 具体表现 | 落点 |
|---|---|---|---|
| 8 | 只解释,绝不动手 | 工具白名单只有 7 个只读工具;没有执行命令、写文件、联网、操作端口的工具 | src/host/tools.ts TOOL_NAMES |
| 9 | 不打扰主 agent | 不写主对话、不把小助手问答注入主 agent 上下文、不取消主任务、不覆盖输入框草稿 | docs/business-logic.md §2;宿主侧无任何主对话写入路径 |
| 10 | 可核查的追问 | 追问会带上本会话既往问答,所以「那它为什么要这么做」能接上上一轮 | src/host/prompts.ts buildMessages(由 src/index.ts 注入) |
| 11 | 模型单独选,不跟随主 agent | 从 DSH 已配置模型里自己挑;没选就不发请求,绝不偷偷用默认模型。选择入口在标题栏(显示当前模型名),点开是一整页:按提供方分组、可搜关键词、失败原因写在页内 | src/index.ts resolveModel;界面 src/client/overlay.tsx buildModelPanel |
| 12 | 失败、超时都给中文提示 | 不静默失败;默认总超时 300 秒 / 空闲 120 秒,可用环境变量收紧(非法值自动回退默认,不会变成 0 或永久等待) | 默认值 src/host/llm.ts DEFAULTS;中文事件 src/host/routes.ts;覆盖 src/host/timeout-config.ts |
| 13 | 只读工作区,越界直接拒绝 | 路径逃逸(绝对路径、..、符号链接出界)一律拒绝并给中文原因 |
src/host/evidence.ts resolveWorkspacePath |
用起来舒服的细节
| # | 特点 | 具体表现 | 落点 |
|---|---|---|---|
| 14 | /compact 只压缩小助手 |
手动整理小助手自己后续回答所参考的上下文;完整问答记录仍保存可翻看;不触发主 agent 压缩 | src/client/overlay.tsx;src/host/llm.ts compactAssistant |
| 15 | 右下角占用圆环 | 显示的是小助手自身的占用,不用主 agent 的数值;估算会标「估算」,拿不到容量就显示「占用未知」 | src/host/occupancy.ts;src/client/overlay.tsx ringNode |
| 16 | 可拖动、可缩放的浮窗 | 不用挤压主对话宽度的固定侧栏;关掉不删记录,再打开继续追问 | src/client/window.ts;src/client/entry.ts |
| 17 | 每个主对话记录独立 | 一个主对话一份落库文件,切换主对话只切换显示,不串记录 | src/host/persistence.ts pathFor |
| 18 | 未读提醒 | 有新回答时右上角按钮从「?」变成「? ·」,硬刷新后也在 | src/client/header-badge.ts |
| 19 | 主对话归档时自动清理 | 归档的主对话,其小助手记录与图片快照随之清理;拿不准就绝不删 | src/host/archive.ts;src/client/archive-watch.ts |
界面里你会看到什么
- 右上角一颗 「?」(有新内容时是「? ·」)。
- 点开是一个浮窗,标题「解释小助手」,第一次打开会给你 4 个快捷问题: 「它现在在干什么」「这一步为什么这么做」「这步实际产出了什么」「哪里可以调整」。
- 点浮窗里的「选择主对话内容」,再去点主对话里的任意一条步骤卡片或一段助手汇报, 就能针对那一条提问;浮窗里会常驻显示「正在围绕这条内容提问」。
- 浮窗底部有
/compact按钮、右下角有占用圆环、顶部有历史记录(可展开单条完整内容)。
环境要求
| 项 | 要求 |
|---|---|
| DSH | >=0.1.7-rc.2 <0.3.0(实测于 0.1.7-rc.2 与 0.2.1-rc.3;本插件依赖 DSH 的 connection / sessionQuery / fs / attachments / llm / tokenMeter 六个服务与两个客户端插槽) |
| profile | web(本插件只在 DSH Web GUI 里有意义) |
| 运行时 | 不需要构建:lib/ 已随仓库提供,clone / 装完即可用 |
| Node | 实测 v24(仅开发时需要:typescript + esbuild 是 devDependencies) |
dsh.client.inject 声明了三个宿主框架包作为能力来源(DSH 自带,不需要单独装):
@deepseek-ai/dsh-client-ui-renderer、@deepseek-ai/dsh-client-ui-session、@deepseek-ai/dsh-api-workspace-controller。
安装
一条命令,装进你的 Web profile(把 web 换成你实际在用的 profile 名):
dsh plugin add --profile web 'git+https://github.com/yunliya-cuter/dsh-explain-assistant.git'
装完不用手改任何配置:本包自带 cordis.patch.yml,并通过 package.json 的
dsh.bundle.patch 指向它,DSH 会在安装后自动把包名写进 profile 的
dsh.profile.bundles 并在进程内重新装配。
然后刷新浏览器(F5)。
验证装好了
- 打开任意一个主对话,右上角工具行里应出现一颗 「?」。
- 点它,浮窗出现,标题是「解释小助手」。
- 点标题栏里的模型按钮(写着当前模型名,没选过则写着「选择模型」)选一个(不选就不发请求),然后点一个快捷问题。
- 几秒内应看到白话中文回答;右下角圆环显示占用百分比或「占用未知」。
从本地工作区开发(改代码即时生效)
不想走 git 安装、就在本仓库上改代码时,直接把本地路径作为 spec 装进来:
dsh plugin add --profile web "$(pwd)"
改完源码后重新构建、刷新浏览器:
npm run build # tsc 产出 lib/*.js + esbuild 打包 lib/client.js
node --test tests/*.test.mjs # 跑测试(本仓库没有 npm test 脚本)
node --test tests/这种带目录的写法会失败,必须带通配tests/*.test.mjs。
卸载
dsh plugin remove --profile web dsh-explain-assistant
插件卸载时会自行移除路由、订阅、监听器、计时器和客户端入口。
它导出什么
| 入口 | 文件 | 说明 |
|---|---|---|
. |
lib/index.js |
宿主半边:六个 DSH 服务、11 条认证路由、持久化、只读工具执行、模型调用 |
./client |
lib/client.js |
浏览器半边:右上角「?」入口、浮窗 UI、选择与拖动缩放 |
./cordis.patch.yml |
cordis.patch.yml |
包自带的组合补丁层,声明本包自己的 loader entry |
./package.json |
package.json |
宿主路由(全部挂在 /api/explain-assistant/ 下,走 DSH 的认证 Fetch,客户端不生成 token):
state、history、history-result、models、select-model、ask(SSE)、
compact(SSE)、in-flight(取消)、forget(归档清理)、mark-read、geometry(记住浮窗位置)。
它把数据存在哪
- 位置:
$DSH_HOME/explain-assistant/sessions/<主对话 id>.json,一个主对话一份文件; 图片快照在$DSH_HOME/explain-assistant/sessions/<主对话 id>/images/。 - 写入方式:每个主对话一条独立串行写队列,写临时文件、校验后原子替换正式文件。
- 它只写自己的目录:不碰工作区文件,不碰 DSH 共享附件,不碰主对话记录。
- 主对话被归档时,对应的小助手记录与图片快照会被清理(判据极其保守:拿不准就绝不删)。
性能:拉伸 / 选中 / 打字(2026-11-07)
用户报「拉伸、选中文字、输入文字时卡顿」。三条分别测、分别归因 —— 它们根本不是同一个原因。
测法:无头 Chrome(真机 GPU:AMD Radeon 集显,1418×746,DPR 1.5)连 3082 实例,
页面里挂 rAF 采样器 + PerformanceObserver(longtask),同一段脚本驱动三种交互:
- 拉伸:按住右下角,每帧一次 pointermove,共 30 帧(每帧 +2.5px 宽 / +1.2px 高);
- 选中:把正文一段文字逐帧扩选(30 步),先把这段滚进视野、并让输入框失焦(真机上点正文就会失焦);
- 打字:每帧插入一个字符(≈60 字/秒的压测;另测了 10 字/秒的真实速度)。
同时用 CDP 的 Performance.getMetrics 取 LayoutDuration / RecalcStyleDuration / LayoutCount。
每组跑 3 次取中位数;每跑一组前把窗口拖回 460×560。
| 场景 | 指标 | 修前 | 修后 |
|---|---|---|---|
| 拉伸 | 帧间隔 p50 / p95 / 最大 | 50.1 / 83.4 / 200 ms | 16.7 / 33.5 / 66.6 ms |
| 拉伸 | 长任务(>50ms) | 14 个 | 0 |
| 拉伸 | 布局耗时 / 次数 | 653 ms / 124 次 | 275 ms / 31 次 |
| 选中 | 帧间隔 p50 / p95 / 最大 | 50.1 / 83.3 / 100 ms | 16.7 / 16.9 / 50.2 ms |
| 选中 | 长任务 | 28 个 | 0 |
| 选中 | 样式重算 / 布局 | 1348 ms / 225 ms | 44 ms / 0 ms |
| 打字 | 帧间隔 p50 / p95 | 33.3 / 33.7 ms | 16.7 / 17.0 ms |
| 打字 | 每个字符的主线程耗时(p50) | 8.9 ms | 0.3 ms |
| 打字 | 布局次数(30 字) | 180 次 | 60 次 |
三个根因
1. 打字:一个字触发 3 次「整页强制布局」。
输入框的光标/选区是自绘的(隐形镜面 + Range.getClientRects)。旧写法的 refresh() 里依次做了
autosize()(写 height:auto → 读 scrollHeight)、syncMirror()(getComputedStyle + 读 clientWidth)、
光标矩形、选区矩形、根元素矩形 —— 每一步都是一次强制布局;而 input / selectionchange / keyup
三个事件每个字各触发一次 refresh。剖面里 autosize 690ms + syncMirror 435ms,占打字总耗时 59%。
改法:三个事件合并到一帧一次;镜面文字只写一次、随后一次把所有几何读完(写→读分离);
排版属性(字体/内边距/宽度)只在尺寸或主题变化时才重写;支持 field-sizing: content 的浏览器
干脆不再由 JS 量高度;光标与选区块的位置从 left/top(布局属性)改成 transform(合成属性);
草稿走 updateDraft 快通道(草稿不进渲染树,敲字时不再重画整棵浮窗)。
2. 选中:一条 CSS 规则让整篇文档每次选区变化都重算样式。
剖面里一次拖选的 RecalcStyleDuration 是 1348ms / 60 次重算 ≈ 每次 22ms —— 而脚本只占 37ms。
二分下来是 dsh-warm-ui 的 html[data-wta-motion-sel] ::selection{background:transparent}:
那个属性常年挂在 <html> 上,于是文档里任何一次选区变化(包括小助手浮窗里的、根本不归它画的选区)
都要给每个元素重算一次 ::selection 样式。实测把属性挪到浮窗自己的滚动容器上:
样式重算 1546ms → 1ms、帧间隔 66.6 → 16.7ms。
改法(在 warm-ui 侧):规则改成 [data-wta-motion-sel] ::selection,属性由 syncNativeSel 挂到
当前真正要画的那块滚动容器上;指针落在别的插件(.dsh-explain-assistant-overlay / .ea-field)里时
一个属性都不挂。小助手这一侧另加两道:量取时沿祖先链的贵读(裁剪区、底色遮挡、层原点)全部缓存;
在输入框里打字时正文高亮引擎直接不醒(selectionchange 在输入框里也会派发,但那跟正文选区无关)。
3. 拉伸:每一帧都在重新栅格化 28px 的毛玻璃和一圈 80px 的投影。
只改宽度、不动任何 JS 时,中位帧间隔就已经是 32.9ms;拆开看:关掉 backdrop-filter 26.5ms、
关掉投影 19.8ms、两者都关 16.7ms。也就是说拖动时真正贵的是绘制,不是 JS。
雪上加霜的是输入框的 ResizeObserver 每帧都触发一次完整刷新(又一次 autosize+syncMirror),
正文区高度的 ResizeObserver 每帧读一次 clientHeight 再写一次 --ea-body-h。
改法:拖动期间给绘制让路([data-resizing=true] 关掉毛玻璃、换成实底 + 小投影),
输入框与正文区的测量都改成「尺寸稳定后再补一次」;松手立刻恢复玻璃。
补记:选中之后「看不见框」的两个真 bug(2026-11-07)
用户反馈「选中后看不见橙框」。拆开验了三层(自绘层挂上没有 / 原生选中色是否被抑制 / 有没有东西盖在 高亮之上),最后抓到两个独立的原因,都是这一轮性能改动引入/放大的:
① 框画到了别处(原点缓存没跟着挂载点走)—— 回归。
高亮层会挂进「真正在滚的那个容器」里,位置按「文字矩形 − 层原点」算。这轮为了省每帧一次
getBoundingClientRect,把原点缓存了起来,但换挂载点时没作废。
真机复现:刚打开浮窗、还没滚过的时候拖选第一段文字 —— 文字在 y=78,框画在 y=153(正好差一个
.ea-turn-a 的原点 76px);差得再多一点整块就跑到可视区外,看起来就是「选中了,但什么都没有」。
旧构建同场景是 y=106(正确),所以这是本轮引入的回归。修法:ensureHost() 换容器时把原点缓存清掉。
② 深色玻璃上原来的蓝色几乎看不见 —— 老问题,这轮才发现。
框的颜色是 rgba(47,107,255,.28)(蓝)。在深色浮窗(底色约 #4c4f56)上按像素中位数实测:
框内 (69,87,132)、框外 (78,80,84),对比度只有 1.13:1 —— 等于没画。
修法:高亮色改成跟随暖色主题的「业务主色」--dsw-alias-state-business-primary(就是 warm-ui 在正文里
画的那个橙 #e59f52)34%,拿不到该 token 时退回原来的蓝:
--ea-selection:rgba(47,107,255,.28); /* 兜底 */
--ea-selection:color-mix(in srgb, var(--dsw-alias-state-business-primary,#2f6bff) 34%, transparent);
同一位置重测:框内 (126,103,80) vs 框外 (78,80,84),对比度 1.52:1(对下一行 1.77:1), 且与正文里的橙框同色 —— 同一个「选中」动作在浮窗内外不再两种颜色。
验证口径(不再用「像素有变化」这种弱指标):用真鼠标(CDP Input.dispatchMouseEvent 按下-拖-松开)
在四个场景各拖一次,量「可见高亮块与选中文字首行的重叠覆盖率」,要求 > 0.6 且框内对比度 ≥ 1.5:
| 场景 | 选中文字 y | 框 y | 偏移 | 覆盖率 |
|---|---|---|---|---|
| 刚打开浮窗就选 | 108 | 106 | −2px | 1.00 |
| 滚动之后再选 | 108 | 107 | −1px | 1.00 |
| 连选第二次 | 108 | 107 | −1px | 1.00 |
| 拖动浮窗之后再选 | 138 | 137 | −1px | 1.00 |
(−2px 是刻意留的 1.5px 内边距,不是误差。)
顺带补了一条:浮窗自己拖边改大小时也会重算高亮(ResizeObserver 观察根节点,拖动过程中先不量、
尺寸稳定后再补一次)—— 以前拖完窗口,选区还停在旧折行上。
还没有解决的(如实写)
- 拉伸过程中中位帧间隔仍在 16.7~33ms 之间:面板要按新宽度重新折行十几屏中文,这部分是浏览器的
文本排版本身(实测布局 275ms/30 帧)。再往下压只有
content-visibility:auto一条路,但它会把scrollHeight变成估算值,影响「打开就滚到最新」「流式跟随」这些已经调好的行为 —— 没做。 - 主对话里拖选仍然慢(实测 p50 100ms+):那一段是 warm-ui 自己的高亮引擎在画,属性挂在
<body>上仍是全文档范围。本轮只把小助手浮窗这一段摘干净了。 - 数据来自单机单浏览器(Chrome / Windows / AMD 集显),不代表低端机与 4K 屏。
焦点:点开就能直接输入 + 「焦点态」只认真正的焦点(2026-11-07)
用户原话:「我希望点开小助手后默认焦点在输入框来直接输入。另外输入框似乎有一些bug, 会出现焦点不在输入框无法输入却光标仍在闪动或者有光圈等不符合直觉的问题。」
两个都是真的,而且是两个独立缺陷,不是同一个问题的两种说法。
一、光标在闪,但一个字也打不进去(假光标)
真机复现(点「?」打开浮窗,什么都不点,直接看光标):焦点停在「?」按钮
(BUTTON.ea-hbtn-btn)上,输入框里那根自绘光标却在闪 —— 抬手一打,字全进了主对话。
量化(每帧采样,5 秒,判据是「没有焦点 && 光标在闪」):
| 违规帧数 | 打开后头 1.5 秒 | |
|---|---|---|
| 旧构建 | 360 | 111 |
| 新构建 | 0 | 0 |
两个根因叠在一起:
refresh()结尾无条件restartBlink()。挂载时那一次刷新就已经画了一根光标; 之后任何一次刷新(窗口改大小、拖浮窗、切主题、字体晚到、滚页面…)都会把失焦后的光标重新点亮。onBlur()只把透明度弹簧推回 0,caretVisible仍是true—— 下一次刷新就认为 「光标本来就该在」,于是重新点亮并恢复闪烁。
修法:加一个唯一判据 focused()(document.activeElement === input 且焦点事件记录为真),
refresh / paintCaret / paintSelection 三处都过它;onBlur 把 caretVisible 一起置假;
窗口整个失焦(切到别的应用)时也把光标收掉。
二、那一圈「光圈」
焦点环原来写在 .ea-field:has(.ea-input:focus-visible) 上,两个问题:
- 文本输入框在 Chrome 里连「鼠标点一下」都算
:focus-visible—— 点进输入框就常驻一圈 2px 内环;深色玻璃上那是接近白色的一圈光(实测rgb(223,225,230)),用户说的「光圈」就是它; :has()的失效重算在部分内核版本会漏掉焦点变化 —— 「焦点早走了、环还留着」,正是 「没有焦点却有光圈」那种不符合直觉的观感。
改成属性驱动:data-focus="keyboard" | "on",由 text-field.ts 在真的 focus / blur 时写。
只有键盘进来的焦点才给环(鼠标点进来不给),所以「有环」⇔「有键盘焦点」,
两者不可能再错开。对比度不变(品牌色 2px,浅色 4.4:1 / 深色 3.4:1)。
三、打开即聚焦:一条明确路径
deliverFocus(ctx):等浮窗挂进文档 + 从加载页切到就绪档 → 投递一次 → 下一帧校验
document.activeElement → 不是它就再补一次 → 仍失败留一条 console.warn(不静默)。
不写成「挂载时调一次 focus() 就算完」:focus() 是下一帧才真正生效的,
期间的焦点可能被宿主或其它模块覆盖 —— 那正是「看起来打开了但打不了字」。
分两种来源,避免「为了能直接输入,就把主对话的焦点抢走」:
- 用户点/按开的(
openedByUser === true)→ 送焦点,且每个会话只送一次(关窗清零); - 后台切会话把浮窗带过来(
openedByUser === false)→ 不碰焦点;若重建前正在这里打字, 重建后把焦点还给新的输入框。
顺带两条:
- 点「发送 / 停止」之后把焦点交回输入框。鼠标点按钮会让焦点停在按钮上,接着打字一个字都进不去 —— 和上面是同一类「看着能打字其实不能」。
- 聚焦不再吃掉主对话里的选区:Chrome 的既定行为是「焦点进文本控件 → 文档选区整体作废」, 而用户的典型动作恰恰是「在主对话里拖选一段 → 点 ? 问小助手」。实测旧构建点开后选区还在 (因为焦点根本没动),新构建如果不管就会把那段橙色高亮凭空抹掉。所以 deliverFocus 在 聚焦前后各留一手,把选区原样还回去(实测:拖选 10 个字 → 点开浮窗后仍是那 10 个字,且焦点在输入框里)。
验证(真鼠标 / 真键盘,CDP 驱动真机)
| 场景 | 焦点 | 自绘光标 | 焦点环 |
|---|---|---|---|
| 鼠标点「?」打开 | 输入框 ✅ | 闪(正确) | 无 |
| 打开后不点,直接打字 | 输入框 | 闪 | 无(文字真的进了小助手输入框) |
| 点正文(失焦) | 浮窗本身 | 不闪 | 无 |
| 失焦后改窗口大小 / 切主题 / 滚页面 | 不变 | 不闪 | 无 |
| 鼠标点回输入框 | 输入框 | 闪 | 无(鼠标焦点不给环) |
| Tab 出去、Shift+Tab 回来 | 输入框 | 闪 | 有(键盘焦点) |
| 点「发送」 | 回到输入框 | 闪 | 无 |
| Esc 关闭 | 回到「?」按钮 | — | — |
还没解决的(如实写)
- 中文输入法组字没有本轮复测:没有自动化手段喂真实的 IME 事件;本轮改动只涉及 「焦点态画不画」,不碰组字链路(composition 事件与 value 都不动)。
- 主对话输入框自己的焦点环不归这个插件管(那是宿主的
:focus-visible规则)。 - 焦点行为在 Chrome / Windows 实测;Firefox / Safari 只有单元测试层面的保证。
上下文压缩提示词:七段底稿 + 可验收判据(2026-11-07)
用户原话:「现在的提示词过于简单且缺少约束性。上下文压缩可以参考 dsh 的提示词针对我们的需求进行优化。」
旧提示词到底出了什么问题(有真实摘要为证)
旧实现只有 6 行 COMPACT_INSTRUCTION,而且复用「回答用户」的系统提示词。翻磁盘上
13 份真实摘要,能稳定复现四个毛病:
| 现象 | 真实例子 | 后果 |
|---|---|---|
| 把系统提示词又抄一遍 | 一份 827 字摘要里有 658 字(80%)是「我是谁 / 四要素 / 说话规则 / 术语表」 | 真正的对话事实被挤掉 |
| 把压缩指令当成用户的话 | 摘要里写「当前这次要求是:把上下文压缩成简短中文摘要」 | 下一轮把指令当用户意图 |
| 把提问脚手架写进摘要 | 「用户没有输入具体问题…本次没有拿到任何步骤依据」 | 摘要里一半是噪音 |
| 只压最近 6 轮 | 10 轮会话只看到后 6 轮 | 前 4 轮的构建、测试、提交号全丢,摘要却看起来完整 |
新提示词做了什么
- 独立系统提示词:压缩器不再带着「解释小助手」的身份读历史,也不会复述回答规则。
- 唯一判据:一个没读过原文的助手,只看底稿必须能答出五件事(用户/约束/结论+依据/未完成/锚点); 任一项答不出就算压缩失败。
- 七段骨架:用户与当前意图、硬约束与偏好(含否定式)、问过的问题与结论、已更正/已否决/已取代、 未完成与待跟进、关键锚点(逐字照抄)、已解释术语(术语 + 一句极简定义,最多 10 条)。标题逐字照抄,空段写「无」,不许省段。
- 来源分级:〔已观察到〕/〔仅据汇报〕/〔无从得知〕必须跟着结论走,不许把「仅据汇报」升级成事实。
- 逐字保留 + 必须丢弃:原话、否定句、路径、命令、数字、报错逐字保留;寒暄、时间戳采样行、 被取代的旧方案、工具原始输出丢掉。
- 冲突消解:同一条事实以最后一次为准,并留下「已取代:旧说法」的痕迹。
- 长度预算:正文 ≤ 2000 字且 ≤ 原文 60%(原文不足 800 字按 800 字算),超限按固定顺序裁剪。
- 再压缩稳定:旧底稿当背景合并,不许原样照抄,也不许丢掉仍成立的锚点。
- 不调用工具 / 空输入也有七段:压缩请求
tools: [];空会话输出七段「无」,不拒绝、不追问。 - 宿主侧硬校验:模型没按七段输出(例如只回一句「我做不到」)→ 判
INVALID_SUMMARY, 不写compactState,上一份可用摘要原样保留。
实测 A/B(3082、同一份 10 轮会话、同一模型)
| 指标 | 旧提示词 | 新提示词 |
|---|---|---|
| 必留锚点召回(19 个:TS2307 / 840÷840 / bc5dc0c / md5 / 16.7ms / 端口纠正 / 用户偏好…) | 10 / 19(53%) | 19 / 19(100%) |
| 七段骨架 | 0 / 7 | 7 / 7 |
| 脚手架污染(「用户没有输入具体问题」等) | 1 处 | 0 |
| 摘要正文 | 1112 字(输入 1454 字,只看到 6 轮) | 1963 字(输入 2963 字,10 轮全看到;≤2000 硬上限) |
| 空会话 | 454 字拒绝(且被当成摘要存下) | 109 字,七段全是「无」 |
| 再压缩一次 | 未测(旧实现会丢掉更早内容) | 19 / 19 锚点仍在,七段完整 |
一句话:旧版是「看起来像摘要」,新版是「换个人接着干也不用重问」。
没做的 / 需要知道的
- 对话提示词:下一节处理;两边的「已解释术语」标签已经对齐。
- 老摘要不迁移:磁盘上已有的旧摘要原样保留,下一次
/compact才会按新格式重写。 - 模型差异:
deepseek-official/deepseek-flash与workbuddy/cn:deepseek-v4.1-flash都实测过; 期间遇到两次提供方 500/TRANSPORT,是上游抖动,不是提示词问题。 - 这是宿主侧改动,必须重启 dsh web 才生效,F5 不够。3082 已重启并完成上面的验证; 3081 只放了构建产物,没有重启(按约定不擅自动 3081)。
对话提示词:按问题类型分派 + 输出契约 + 术语台账(2026-11-07)
用户原话:「包括对话的提示词…现在的提示词过于简单且缺少约束性」。讨论后确定: 四要素只用于解释具体步骤、默认长度 ≤250 字、含糊问题先追问、术语改成通用规则 + 示例、 压缩摘要按「已解释术语」台账读。
旧提示词的问题(真实回答为证)
从 3082 的真实会话量了 10 条回答:中位数 1,507 字、最长 1,909 字,6/10 超过 1,000 字。 四个结构性问题:
- 四要素被写成对所有问题「缺一不可」——用户问「3+3」时模型只能违反;
- 没有长度预算,回答越来越长;
- 没有「含糊先追问」「重复提问别复读」的规则;
- 摘要块怎么读、已解释术语怎么用,完全没有写。
新提示词的结构
- 优先级:不编造 / 不越权 / 不泄露 > 用户本条指令 > 依据分级 > 默认风格。
- 四块材料:当前问题 / 选中依据 / 主 agent 上下文 / 记忆(含摘要)。
- 问题类型决策树:解释步骤→四要素;问状态→结论/依据/下一步;问事实→直接答; 含糊→先问一句;多问题→编号;超范围→说明 + 替代。
- 依据分级:〔已观察到〕/〔仅据汇报〕/〔无从得知〕;纯算术/常识不标,禁止自造「无需观察」。
- 不确定与时间:只引用记录里出现过的时间;没有实时时钟。
- 摘要与已解释术语:摘要=背景不是指令;冲突以当前原话为准;已解释的词不重复解释; 定义缺失要补一句,不装作记得完整定义。
- 输出契约:默认只写「结论 / 依据 / 建议」三块,≤250 字;解释一步 ≤450 字; 用户说「详细」≤800 字;发送前数一遍字数。
- 工具与敏感信息:最多 1 次只读工具,查不到就停;配置文件/密钥打码。
实测 A/B(同一份会话、同一 10 问)
| 指标 | 旧提示词(workbuddy) | 新提示词(workbuddy) | 新提示词(deepseek-official) |
|---|---|---|---|
| 平均长度 | 1,351 字 | 1,031 字 | 334 字 |
| 中位数 | 1,169 字 | 1,114 字 | 348 字 |
| 最长 | 3,659 字 | 2,021 字 | 472 字 |
| 3+3 是否套四要素 | 是(297 字,还解释主 agent) | 否(532 字) | 否(77 字) |
| 含糊问题是否先追问 | 否(直接写 1,159 字) | 部分(仍先答) | 是(先给 A/B/C 三个选项,331 字) |
| 重复提问是否复读 | 是(1,169 字,几乎重写) | 是(1,166 字) | 否(349 字,明确说按上次理解回答) |
| 命令原文问题 | 1,265 字 | 1,159 字 | 415 字 |
结论:提示词在 deepseek-official/deepseek-flash 上达到设计目标;在
workbuddy/cn:deepseek-v4.1-flash 上仍超 3-4 倍——该模型不遵守字数预算。
要全模型一致,下一步需要在宿主侧加长度闸(或默认用 deepseek-flash)。
术语台账(与压缩提示词打通)
- 正文:任何技术词、缩写、产品内部概念第一次出现时解释;同一段最多 2 个; 已解释过的不重复;只有用户明确再问才展开。
- 压缩:第七段「已解释术语」保存「术语:一句极简定义」,最多 10 条、每条 ≤30 字; 再压缩时并进新台账,已经解释过的词不能丢。
- 实测:一次压缩从 10 轮对话里抽出 7 条术语(构建 / 提交 / md5 / p50 / 长任务 / import / profile), 定义都在 30 字内;再压缩后 19/19 锚点仍在。
- 标签统一:摘要注入用「【本次对话此前的整理摘要(含「已解释术语」)】」, 正文提示词引用同一个名字,模型不会对不上号。
没做的 / 需要知道的
- 老摘要没有第七段:下一次
/compact会补上;在补上之前,正文按「摘要里没有台账」处理,不影响使用。 - 长度预算在 workbuddy 上不生效;这是模型差异,不是提示词措辞能完全解决的。
- 871 条测试全过(新增 11 条对话提示词契约测试;压缩契约从六段改七段,相关夹具同步)。
流式跟随:在底部才跟,问句顶到可视区顶端就停(2026-11-07)
用户原话(第二版,也是最终定义):「发出消息后,若用户正在看的地方是最新消息 (发出前的最新消息)的底部,则自动跟随最新消息,直到用户发出的最新消息顶到 可视页面的最上方停止,这样方便阅读」。同时明确否掉了上一版:「全程跟随不要」。
一、规则只有三条
| 事件 | 判定 |
|---|---|
| 发送 | 那一刻视口停在最新内容底部(距底 ≤ 24px,约一行)才跟;不在底部就一个像素都不动,包括思考阶段和首字到达 |
| 跟随 | 跟到用户这一问的气泡顶边碰到可视区顶边为止 |
| 停止 | 到达停止线后本问不再自动滚动(终态);用户中途自己上滚则立刻暂停,滚回底部或点「回到最新」可以恢复 |
停止线越过的那一点点会补回去:跟随是缓动的,等这一帧量到"已经到顶"时, 问句往往已经滑出上沿十几到几十像素;补回之后问句正好贴顶, 屏幕上就是「我问的那句在最上面 + 一屏回答」——也就是用户的阅读起点。
二、实现是显式状态机,不是每帧拿位置猜
ctx.followPhase:idle(不跟)/ following / paused(用户上滚,可恢复)/ stopped(到线,终态)。
只被三个事件改写:发送 / 用户滚动 / 到达停止线。
上一版(0.3.1)是"每帧用几何位置重算",实测的后果是:用户滚回底部,下一帧又被同一条规则 判成暂停,等于永久锁死。这一版不会再出现这种事:状态只前进,不会自己回退。
三、真机实测(3081,正文区 348px,CDP 真事件)
| 场景 | 结果 |
|---|---|
发送时贴底(st = max,差 6px) |
跟上了:scrollTop 2956 → 3196,问句 top 从 242 走到 0 |
| 到达停止线之后(内容继续从 3272 长到 4384) | scrollTop 一直停在 3196,一个字都没再滚(+1112px 内容全在下方) |
| 发送时不在底部(距底 800px) | 全程 scrollTop 1061 → 1061(内容从 1946 长到 2295 也没动) |
| 跟随中真滚轮上滚 1 格 | 立刻停:scrollTop 2087 冻结(内容 2307 → 2631 也没动),「回到最新」出现 |
| 滚回底部 | 恢复跟随(jump 收起) |
| 到达停止线后再滚到底 | 不恢复(终态;滚动权已经交还用户) |
| 回答短于剩余视口(问句永远到不了顶) | 一路跟到底,不伪造停止线、不补空白 |
四、和上一版(0.3.1)的差别
| 0.3.1(已作废) | 0.3.2(本版) | |
|---|---|---|
| 跟多久 | 一直跟到回答写完 | 跟到问句顶到可视区顶端就停 |
| 发送时不在底部 | 也跟(还先跳到最底) | 完全不跟,一个像素都不动 |
| 停止后 | 一直跟 | 终态;只有用户自己滚或下一次发送才会变 |
| 用户上滚 | 立即停(可恢复) | 同左,另外此时才显示「回到最新」 |
五、没做的 / 需要知道的
- 「回到最新」只在用户上滚造成的暂停里出现。到达停止线之后不给它 —— 那不是在"暂停跟随",而是这一问的跟随已经结束,用户此时在正常阅读。
- 发送时不在底部的那种情况,后面就算你自己滚到底,这一问也不会开始跟随 (规则是"发送那一刻"判定的)。如果你希望"滚到底就接管过来跟",说一声我改。
- 采样来自单机(Chrome / Windows / AMD 集显),不代表低端机与 4K 屏。
跟随手感:去掉每帧多余的量算、收尾不再上移(2026-11-07)
用户反馈:「跟随动画没有之前丝滑(在这两次改动之前),而且内容输出完成内容后会往上移动一下」。
一、丝滑:跟随期间别再量阅读位置
上一版给「落地/翻页不跳」加的阅读位置锚点,在每次重画时都会量「活体块 + 历史里还看得见的头两轮」。
问题在于跟随期间 wasAtBottom 常判 false(跟随循环本身有 2080px 的缓动余量,而判定容差只有 24px),
于是每一帧都做 35 次强制布局 —— 而量出来的位移必然是 0,纯开销。跟随循环每帧都在写 scrollTop,
多几次强制布局就掉帧,看起来就是「不丝滑」。
修法:跟随进行中(isBusy && followPhase === 'following')一律不记锚点,
滚动权完全交给跟随循环;锚点只在用户自己在读(paused / stopped / 落地那一帧)时用。
二、收尾上移:活体块和历史记录差的这 20px
- 跟随期间视口钉在内容底部(
scrollTop == max)。 - 落地那一帧,「当前这一问」的活体块换成历史记录,记录多了时间行/脚注等,实测 616px → 636px(+20px)。
- 底边没变、内容凭空高出一截 → 正文被顶上去 20px(用户看到的「往上移动一下」)。
修法:落地那一帧,原本贴底或正在跟随的人再钉一次底(scrollTop = scrollHeight - clientHeight)。
不是贴底的人(在翻上面的历史)不受影响,走阅读位置锚点。
真机实测:落地前后 scrollTop 2120(max 2156)→ 1626(max 1626),落地后 gap = 0。
三、停止时不再「补一刀」
上一版到停止线时用一次 scrollTop -= overshoot 把问句贴顶 —— 这一刀本身就是一次可见的跳动。
现在把停止线并进缓动的目标里:
target = min(内容最大可滚动位置, 让问句顶边贴住可视区顶边的位置)
缓动自然收敛到停止线,越界量≈0,不需要事后修正。问句在内容里的位置是固定的, 这个目标值不会随正文变长而漂移。
四、诚实边界
- 这边的无头浏览器只有 9fps(rAF 被节流),量不出「帧级丝滑」:帧间隔、单帧位移这些 数字在真机上没有可比性。能确定的是「每帧少做了 35 次强制布局」和「停止从跳变改成收敛」这两件事; 手感是否回到 0.3.0 的水平,需要你在真实窗口里看一眼。
- 收尾那 20px 是实测值(616→636);如果同一条记录还带「工具过程」,差值会更大, 但修法相同(落地说到底还是钉底)。
「正在连接模型」这一行不再顶动正文(2026-11-07)
用户:「内容输出完毕后还是会跳一下,我推测是因为『正在连接模型』这几个字导致的,所以我们需要优化这几个字的显示方式」。
一、这一行确实是跳动源(实测 24px)
状态行(.ea-running:小转圈 + 「正在连接模型…」/「正在解释…」+ 停止按钮)实测高 24px。
旧写法是「没吐字才画、一吐字就整行删掉」—— 首字到达那一帧,下面问句和正文整体被顶上去 24px。
二、改成「恒定高度槽位」
只要这一问还在跑,槽位就在(同高度、同基线、同字号);首字到达只是把里面的内容设成
visibility:hidden,节点不删、布局不动。连接态显示「正在连接模型…」,流式态是一条空着的 24px 带
(视觉上就是问句与正文之间的一点点留白)。
约定(写进代码注释与本节,避免以后随手改回去):这一行的高度在整个回答期间恒定; 只有整条回答结束(busy → idle)才随活体块一起消失。
三、落地按「正文块」锚定,而不是钉底
上一版在落地时「再钉一次底」。钉底固定的是底边,而历史记录比活体块多了时间行(正文上方) 和脚注(正文下方)—— 多出来的脚注会把正文整体往上顶,用户看到的还是跳。
现在改成量正文块自己的顶边:落地前后让它停在同一个屏幕位置,脚注露出来多少随它去。
并且落地这一帧之后再补两帧(落地时还会排队 scheduleFoldCheck 的折叠、历史区按签名重挂),
把残余位移收敛到 0。
真机实测(3081,548×519,正文 348px):
| 时刻 | 正文块 top(相对正文区) | scrollTop |
|---|---|---|
| 落地前最后一帧 | 84 | 2078(max 2347) |
| 落地后第一帧 | 84(位移 0) | 1534(max 1803) |
四、诚实边界
- 这套锚定只保证「正文块顶边不动」。如果同一条记录里正文自己的高度变了(例如最后一块 代码块补了高度),正文内部仍会有像素级重排 —— 那是内容本身的排版,不是滚动跳变。
- 补两帧是在「用户没有自己滚动」的前提下做的;用户这两帧内自己滚,仍以用户为准。
落地零位移:状态带搬出正文 + 活体块与记录同构(2026-11-07)
用户(第二张截图):「用户发的这个在内容生成完毕后会往下移动一下」。
一、上一版为什么会往下移动:补偿做了两遍
落地那一帧同时跑了两套补偿:正文块锚点(让正文块不动)和阅读位置锚点(让这一轮的顶边不动)。 两套都朝同一方向补,等于补了两遍 —— 屏幕上就多走一段,方向正好是向下。 现在:落地这一帧只允许一个补偿来源(正文块锚点生效时,阅读位置锚点整帧跳过)。
二、仍然差 14px:活体块和历史记录不同构
历史记录在问句和正文之间有一行 .ea-turn-meta(右侧时间,行高 16px、上边距 -6px,实际占 10px),
活体块没有 → 落地时「问句↔正文」的间距变了,只锚正文的话问句会往下挪 14px(实测)。
修法:活体块里加一个同 class、同高度的不可见占位(.ea-live-meta),两边结构一致。
三、「正在连接模型…」搬到输入框上方的状态带
这几个字原来长在正文里(问句下面、正文上面):首字到达整行删掉 → 下面整体上移 24px;
落地时又随活体块消失 → 再跳一次。现在它住在输入框上方一条高度恒定 24px 的状态带里
(.ea-status-bar,在滚动区之外),正文区的结构从连接到最后一个字都不变。
这也顺带把它放到了「系统的声音」该在的位置:小一号字、次要色、贴着输入框。
四、真机实测(3081,停止线场景:问句贴在可视区顶端)
| 量 | 落地前 | 落地后 | 位移 |
|---|---|---|---|
| 问句气泡顶边 | 1 | 1 | 0 |
| 正文块顶边 | 91 | 91 | 0 |
| scrollTop | 2927(max 3606) | 1799(max 2476) | 内容上方缩短 1130,补偿后画面不动 |
五、约定(别再改回去)
- 状态文案只能在滚动区外(状态带),不得回到正文流里;状态带高度恒定。
- 活体块与历史记录必须同构:问句、时间行、正文三者相对位置一致;记录多出来的脚注只会出现在正文下方。
- 落地补偿只允许一个来源,且落地后再补两帧收敛(折叠/重挂是异步的)。
压缩请求的接线:七段骨架必须真的发给模型(2026-11-08)
一、现象
用户点 /compact 后每次都失败,提示:
这次整理没有按约定的七段格式输出,已经丢弃。上一份可用摘要仍然保留,你可以稍后再试一次。
真实记录(~/explain-assistant/sessions/session-41b89136….json)里连着三次:
| 时间(北京) | 结果 | 耗时 | 输出 tokens |
|---|---|---|---|
| 11-07 22:42 | invalid_summary | 10.5s | 1535 |
| 11-08 08:09 | invalid_summary | 27.0s | 4250 |
| 11-08 08:23 | invalid_summary | 18.3s | 2123 |
二、根因
aeb3624(11-07 16:03)把压缩规则从「回答问题的系统提示词」搬进了独立的 COMPACT_SYSTEM_PROMPT,
并在 BuildMessagesDeps 加了 systemPrompt? 选项 —— 但调用点从来没传过它:
src/index.ts:535 buildMessages(question, payload, { history, compactSummary, purpose: mode, … })
src/host/prompts.ts:518 text: deps.systemPrompt ?? SYSTEM_PROMPT ← 落回「回答问题」那份
于是压缩请求的系统消息是回答用的提示词,七段骨架一个字都没进请求; 收尾指令却写着「按系统提示里的七段骨架输出」—— 模型只能自己编标题,校验器逐字比对,必然全判不合格。
实测(直接跑仓库源码组装消息):
system === COMPACT_SYSTEM_PROMPT: false
system === SYSTEM_PROMPT: true
七段标题在 system 消息里: 0 / 7
七段标题在整个请求里: 0 / 7
三次失败的原始输出也印证了这一点:后两次都写满了七段,但 6 个标题是自编的
(# 一、原始对话核心内容、## 二、用户的要求与约束…)—— 模型知道「要七段」,不知道七段叫什么。
三、为什么 878 条测试没抓到
测试是自证的:tests/compact.test.mjs 把 COMPACT_SECTION_TITLES.join('\n') 直接当模型输出喂进去,
等于拿标准答案考自己;提示词那批测试只验「提示词正文写了什么」,没有一条断言压缩请求的系统消息含七段骨架。
接线漏了,测试天然看不见。
四、改法
- 默认值下沉:
buildMessages自己按purpose选系统提示词(compact ? COMPACT_SYSTEM_PROMPT : SYSTEM_PROMPT)。 默认值放在这里而不是调用点 —— 少一个「记得传」的位置,就不会再漏。 - 收尾指令自包含:
COMPACT_INSTRUCTION直接把七个标题写进指令,即使系统提示再丢也不会自编结构。 - 校验归一化:比对前去掉行首
#、空白、全角/半角括弧差异;失败信息指名缺第几段。 归一化挡的是「只差一个空格」的误杀,不挡「自编结构」——后者仍然判不合格。
新增 8 条回归测试(tests/compact-wiring-2026-11-08.test.mjs),其中一条是反例:
把系统提示换回回答用的那份,断言必须变红。
五、约定(别再改回去)
- 压缩请求的系统消息只能是
COMPACT_SYSTEM_PROMPT;七段标题必须在整份请求里 7/7 出现。 - 收尾指令必须自包含标题 —— 不要写「按系统提示里的骨架」,那句话在没有系统提示时是空指针。
- 校验可以放宽标点,但不能放宽「段数与段名」:缺一段也判不合格。
「一大段一大段」:改的是说话方式,不是渲染(2026-11-08)
用户原话两句,必须一起读:
「现在小助手生成的内容经常一大段一大段的,可读性不是很好。有没有什么办法可以增强可读性并且不丢失必要信息」 「不,我不是要你改渲染方式,而是改小助手说话的方式」
第一版做的是渲染层重排(按句号切段)。被明确否掉,已整体回退,仓库里不留这套代码。 下面这版只改模型怎么写:判据全部落在模型原文上,界面一个字没动。
一、先说量法
拿 6 个固定问题(同一份依据材料、同一个模型 cn:deepseek-v4.1-flash),新旧提示词各跑一遍,
只量模型吐出的原文:行数、最长一行的字数、一行里写了几句、结论行/建议行有没有、锚点召回。
锚点 = 材料里必须原样出现的 6 个事实:962035、cursor_test.png、750,450、1674,418、1280,720、visible=True。
二、实测(6 个问题,旧提示词 vs 新提示词)
| 指标 | 旧 | 新 |
|---|---|---|
| 平均行数 | 14 | 8 |
| 最长一行字数 | 176 | 53 |
| 一行写了多句的行 | 3 | 0 |
| 平均字数 | 1041 | 322 |
| 锚点召回 | 35/36 | 36/36 |
| 第一行是「结论:」 | 0/6 | 6/6 |
| 最后一行是「建议:」 | 0/6 | 6/6 |
| 超过 150 字的段落 | 19 | 6 |
一条对照(同一问题「它现在在干什么」):旧的是 17 行、单行最长 150 字、加粗小标题套小标题;
新的是 7 行、单行最长 56 字,第一行结论、最后一行建议,962035 / (750,450) / (1674,418) 一个不少。
三、根因:为什么之前写了「短一点」也不管用
- 只写在系统提示里,模型基本不理。 第一版把形状规则放在系统提示末尾,实测 39 行、 单行 215 字、一行写 8 个句号 —— 等于没写。
- 本提示词的优先级表自己把答案写出来了:它写着「用户本条消息里的明确指令」高于 「默认格式」。所以格式要求必须以用户这一轮的最后一条要求出现。同一段话挪到用户消息末尾, 立刻收敛到 8-10 行、单行 ≤50 字。
- 没有可数的形状。「简洁」「清晰」这类词模型无法自检;「第一行必须是『结论:』」「每行只许 一个句号」「不超过 15 行」它可以逐条数。
四、改法(src/host/prompts.ts)
- 新增
OUTPUT_SHAPE_INSTRUCTION:形状条约的唯一出处,系统提示与用户消息共用同一份措辞 (两份各写一遍迟早会漂移)。 buildMessages把它追加在用户消息的最后一块;压缩请求不追加(压缩有自己的七段骨架)。- 系统提示里同步留一份【回答形状(硬约束)】,并补上正例(逐行写给人看),把「结论 / 依据 / 建议」 从描述改写成可数的行级规则。
- 反例修掉:原来第 45 行写着「不写小标题」,和「第 1 行:结论:」直接打架 —— 模型会两个都不写。 现在写成「不写大标题(但行首的『结论:』『建议:』这种短标签必须写)」。
- 两条守恒规则:至少 2 条依据(防住「只给一句结论」的退化)、数字/路径/命令/报错原文照抄原样 (防住「约 96 万」这种四舍五入丢事实)。
提示词 2743 → 2943 字,仍在既有「<3000 字」闸门内。
五、约定(别再改回去)
- 形状要求必须同时出现在系统提示和用户消息末尾。 只改一处,实测都会退回长段。
- 「不写小标题」这类旧措辞不能再出现:它会连「结论:」一起压掉。
- 术语表、依据分级这些业务规则不动;这条约束只回答「怎么写」,不回答「写什么」。
- 形状必须是可数的(行数 / 每行句号数 / 是否有结论行),别改成「简洁」「清晰」这种没法自检的说法。
已知限制(如实写)
- 归档墓碑是内存态:插件重启后丢失。若重启后仍有"晚到的写入",理论上可能把已清理的文件重建出来; 兜底是下一次归档检查。
- 修复前落库的老记录补不出依据:早期版本落库时没写
evidence/tools/images, 这些老记录展开后仍然是空的,无法追溯补齐。 - 占用百分比是启发式估算:小助手不是 DSH 会话,没有可精确测量的 Session, 所以按固定文本密度估算并始终标注「估算」;拿不到模型容量时显示「占用未知」,不编造数字。
- 依据分级第三级较难出现:「无从得知」需要一条"未完成 / 不可读"的卡片, 在正常数据下不易构造,因此这一级主要是防御性设计。
- "切换主对话不串记录"只在接口层验证过:页面上的点击级验证尚未做。
这个仓库里有什么、没有什么
有:src/(TypeScript 源码)、lib/(构建产物,随仓库提供)、tests/(895 条测试)、
scripts/(构建与本地部署脚本)、docs/business-logic.md(业务需求,权威基线)、
docs/implementation-plan.md(实施方案)、README.md、LICENSE。
没有(被 .gitignore 排除,不进公开仓库):
node_modules/、*.tgz(打包产物)、package-lock.json、
以及 docs/ 下的内部工作底账(HANDOFF.md、status.md、gap-analysis.md)与
docs/evidence/(含本机访问 token 的取证报告,以及 50MB 的本地 GUI 截图)。
排除理由:这些是本地审计材料,含本机 token 与运行截图,不属于插件本身。
测试
node --test tests/*.test.mjs
当前 895 条测试全部通过(0 失败)。测试覆盖:协议与错误码、会话归属与工作区边界、 只读工具白名单与串行执行、SSE 顺序与取消、原子写入与损坏恢复、压缩事务、 React/store 状态、指针与键盘交互、归档清理等。
本项目的经验之谈:测试全绿 ≠ 页面上没问题。多条真实缺陷(未读徽标从不显示、 滚动抽搐、落库缺字段)都是测试全绿时在真实页面上发现的,因此每个改动都要求在 真实页面上复核一次。
No comments yet. Be the first to write one.