Skip to content

dsh-reply-visual

Verified

@yfwu2020/dsh-reply-visual · v0.2.0 · MIT · Web UI

DSH 回复可视化(图解):把 AI 回复变成立即看得懂的单页图解——模型写单文件 HTML + 内联 SVG,右侧栏沙箱 iframe 渲染

Install

dsh plugin add @yfwu2020/dsh-reply-visual

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-reply-visual · 回复可视化(图解)

在 DSH Web 界面里,把一条 AI 回复变成一页看得懂的图解: 每条定稿回复下面多一个「图解」按钮 → 点一下,右侧栏打开「图解」页—— 模型直接产出单文件 HTML(内联 SVG),在沙箱 iframe 里渲染。

release npm license DSH plugin


它解决什么问题

长回复难读。一段三千字的分析里,真正的结构(三个层次、两条分支、一个取舍)是空间关系, 而 Markdown 只能把它摊成一条竖线——你得在脑子里重新拼一遍。

这个插件把那条回复交给模型,让模型画一页:摘要卡、流程图、时间线、对照矩阵、结构树、 示意图……画成什么样完全由模型决定。插件只做两件事:把回复交给模型,和给一个沙箱。

它和"让模型输出 Mermaid"的区别在于:没有图表 DSL、没有内置渲染器、没有图型白名单。 模型直接写一页 HTML + 内联 SVG,所以能画的东西只受表达能力限制,不受图库限制。

长什么样

页头(模型/档位/耗时/字节)· 三段切换「图解 / 源码 / 原文」· 「历史」「重画」「字号」「复制源码」「保存 .html」。

图解页:工具栏 + 模型画出的整页图解
深色主题 历史下拉:按对话顺序排、可筛选、可跨会话
深浅两套配色(页面自己带一套,跟随系统) 历史:本会话画过的页,按对话顺序排,点了直接回放

三个视图各管一件事:

视图 内容
图解 流式期间是进度卡(已画多少字 / 秒表),整页到齐后换成模型画的页面
源码 模型产出的 HTML 原文(可复制)。页面不完整时这是唯一出口
原文 这条回复本身,用 DSH 自带的 MarkdownText 渲染——不调模型、不截断,永远可用

「原文」是兜底,也是「图看不懂时回去看原话」的去处。

等待期:20~70 秒不是空白

生成一页要 20~70 秒(长回复实测 57s / 64s)。这段时间侧栏分三段,各管一段、不重复表达:

理解中:一道匀速向右游走的声波 绘制中:线稿按字数逐行长出来,末尾浮出橙色光标
① 理解中(还没有产出)
声波——只表达"在活动",不确定态不假装有进度
② 绘制中 → ③ 收尾
线稿按字数生长;画完后末尾浮出闪烁光标

状态行给真实数字(已画字数 + 秒表),动画只负责"还在长"——两者分工。 不显示百分比:总量未知,报百分比就是编的。

历史与翻页

同一会话里画过的页都能回去,三个入口殊途同归:

入口 定位到
每条 AI 回复下面的「图解」 就是你点的那一条
会话头部的「图解」图标 本会话上次画的那一条
工具栏的「历史 N ▾」 本会话任意一条(下拉列表)

列表按对话顺序排(最早的一轮在最上面,和聊天记录同向),不是按"什么时候画的"—— 重画一条旧回复只会刷新它的时间,位置不动。位置的依据是会话日志里那条 assistant/message 的序号(seq,会话内单调递增),它天然等于"这段对话的第几步"; 定位不到的旧条目退回按生成时间排,并统一排在最后。

  • 本会话:一条平铺的列表,不分组(只有一段对话,给分组头是噪音)。行里给时刻、 相对时间、字节数,以及第几轮。
  • 全部会话:按会话分组,会话之间"最近活跃的在前",组内仍是对话正序;分组头是 会话标题(host 从会话日志折出来的,取不到就显示会话号前 8 位),右侧给 "几条 · 最近什么时候画的",当前会话标「本会话」。

筛选框、↑↓ 选择、↵ 打开、Esc 关闭、「全部会话」开关都在。 「页面自己的标题优先」——显示的是产出 HTML 里的 <title>/<h1>/<h2>, 不是回复首行那句话片段。

翻页贴在页面右缘(▲ N/M ▼),鼠标悬停才显形:序列和下拉同一个顺序 (对话正序),到头循环,只有 ≥2 条时才出现。切页不闪骨架屏(保留当前页,只转一个小圈)。

回放走 /result,token 命中即返回——一次模型都不调。

安装

# 从 npm(推荐)
dsh plugin --profile web add @yfwu2020/dsh-reply-visual

# 或从 GitHub Release 的 tgz(同一个产物,不走 npm)
dsh plugin --profile web add /path/to/yfwu2020-dsh-reply-visual-0.2.0.tgz

# 或从本地目录(开发用:改完不用重装,只重建)
dsh plugin --profile web add link:/path/to/dsh-reply-visual

它在 DSH 里落成三处:

位置 内容
~/.dsh/profiles/web/package.json "@yfwu2020/dsh-reply-visual": "<版本或 link:路径>"
~/.dsh/profiles/web/node_modules/@yfwu2020/dsh-reply-visual 软链 → 插件目录
profile 的 dsh.profile.bundles 包名(按包名引用,与路径无关)

⚠️ 装完要重建进程:host 半是新的 bundle,改完需要重启 dsh web(或走你现有的热重载通道), 之后硬刷新浏览器(Cmd/Ctrl+Shift+R)。client 半之后的改动会被 client-hmr 就地替换,不用刷新。

要求:DSH 运行时 >=0.1.7-rc(见 package.json 的 peerDependencies)。 0.1.7-rc 起 createSystemMessage 去掉了插件署名参数,更旧的运行时上行为会不一致。 另外 npm 上这些包的 latest 还停在更旧的 0.0.1-rc.x,缺 WebServer 等 API,装上会起不来。

配置项

改 profile 的 cordis.patch.yml 里 id: reply-visual 那一行的 config:。

模型与生成

键 默认 说明
provider / model 空 固定模型路由;留空 = 跟随「设置 → 默认模型」
reasoningEffort low 画图靠长输出、不靠深推理;要更讲究可调 high
temperature 0.4 画图要一点自由度;别调到 1 以上
maxTokens 0 单页输出上限。默认 0 = 不传这个参数,交给模型/供应商自己的上限(早先默认 8000 会把页面切在 SVG 中间)
timeoutMs 180000 单次生成超时
maxSourceChars 24000 原文上限,超出取头 60% + 尾 40%(结论在尾部,不能砍尾)

页面渲染与手感

键 默认 说明
allowScripts true iframe 是否放脚本;false = sandbox="" 纯静态
wheelSmooth true 滚轮走"跟手 + 惯性"(触摸手感);false = 中间段落交回原生滚轮,只在边界做橡皮筋
wheelSmoothFactor 0.22 惯性逼近系数(0.02~0.8);越小越"拖沓",越大越"利落"
elastic true 橡皮筋总开关(prefers-reduced-motion 下自动关)
elasticMax 140 橡皮筋最大可拉距离(像素);0 = 关闭
elasticResist 0.5 橡皮筋阻尼(0.05~1,越小越"沉");曲线非线性,越拉越沉
elasticStiffness 0.18 回弹刚度(0.005~0.3,越小越软);配 elasticDamping 0.52 时不过冲
elasticDamping 0.52 回弹阻尼(0.4~0.98);调高会重新开始过冲、弹两下
elasticTop false 顶端是否也做橡皮筋(默认关:到顶继续拉没有过拉效果)
elasticMaxHoldMs 10000 橡皮筋硬上限(0 = 不限):到点强制回弹,继续拉伸不重置
elasticGestureGapMs 120 两次滚轮/触摸之间超过它就当作"新的一次操作" → 立刻弹回
bottomGap 0 页面底部留白像素(默认关):让最后一行避开右下角胶囊;观感是死空白,所以改用 elastic 解决
maxPageBytes 2000000 「页面偏大」的提示阈值(不是拒绝线;只有 >16MB 才拒绝渲染)

缓存

键 默认 说明
resultCacheTtlMs 0 回放的时间判据:默认 0 = 不限时(文件在就回放);设正数才会到点强制重新生成
maxCacheEntries 200 条数上限:result/ 与 prepared/ 各自保留最新这么多
cacheMaxBytes 33554432 缓存总字节预算(32MB):超了从最老的开始删
cacheMaxAgeMs 0 按龄清理(0 = 不限龄)
maxRequestsPerMinute 20 本地限流

自定义这两份规范

键 默认 说明
skillPath 包内 skills/visual-page/SKILL.md 可视化规范(做什么 + 环境是什么样)
designPath 包内 skills/page-design/SKILL.md 审美参考(怎么做得好看);填 off = 不注入,留空 = 用包内那份

缓存会一直涨吗

不会无界增长——生成成功后自动清扫,启动后 5 秒也会扫一次。四道闸门:

闸门 默认 清什么
按条数 200 result/ 与 prepared/ 各自只留最新 200 份
按总字节 32MB 整个缓存超预算时从最老的删起
按龄 关 想自动过期就设 cacheMaxAgeMs(比如 7 天)
临时文件 1 小时 写盘中途退出留下的 .tmp 残留

内存另有独立一道闸门(maxCacheEntries + 64MB 字节预算):被挤出内存的只是副本, 磁盘上还在,再点开会从磁盘回读。

什么情况下会重新生成(这是实际用起来最关心的一条):

动作 会不会重新生成
关掉标签页再点「图解」(同一条回复) 不会,直接回放
切换会话再切回来 不会
刷新浏览器 / 重启 dsh 标签会消失(右侧栏布局是内存态),但点会话头的「图解」即可找回;在原来那条回复上再点也不会重新生成
隔了几小时/几天再点 不会(resultCacheTtlMs 默认不限时)
升级了插件(改了规范) 不会——token 只由内容决定,回放时会提示"可点重画用新规范重生成"
缓存被三道闸门挤掉 会
你点「重画」 会(唯一主动重新生成的方式)

随时体检:GET /reply-visual/api/ping 报告路由、档位、缓存目录/文件数/字节、上次清扫删了几个:

curl -s http://127.0.0.1:3080/reply-visual/api/ping | python3 -m json.tool

想立刻清零:缓存在 ~/.dsh/reply-visual/,直接删目录即可(下次用到会重建):

rm -rf ~/.dsh/reply-visual

注意区分:这只删缓存。你点过「保存 .html」落到工作区 reply-visual/ 的文件是你的文件, 插件不碰、也不会自动清。

安全边界(刻意这么设计)

模型写的页面跑在 sandbox="allow-scripts allow-forms allow-modals allow-popups" 的 iframe 里, 永不加 allow-same-origin:于是它跑在不透明源里——能算、能画、能发请求, 但读不到本页的 DOM / Cookie / localStorage,也不能导航顶层窗口。

  • 页面只经 Blob URL 进 iframe,永不作为同源顶层文档打开。「保存 .html」落下的是模型原页 (未打补丁),要看得走 DSH 自带的文件预览沙箱。
  • 脚本是能跑的,而且是全的(真机实测,同一个 sandbox 串):内联 classic script、setTimeout、 requestAnimationFrame、element.animate()、canvas 2d + toDataURL、svg.getTotalLength()、 PointerEvent + setPointerCapture、ResizeObserver / IntersectionObserver、<details>、 :has() / 容器查询 / color-mix / accent-color 全部可用。 被挡的只有存储与剪贴板:localStorage / sessionStorage / document.cookie / indexedDB.open 一律 SecurityError,navigator.clipboard 是 undefined(不透明源的必然结果,不是配置问题)。
  • 显示时才给页面注入几个补丁(保存在 .html 里的仍是模型原页): <meta viewport> + 极小 reset(插在 doctype 之后——插在它之前会把文档推进怪异模式)、 滚轮手感、橡皮筋、可选底部留白。
  • 侧栏宽度只有 320720px,而模型习惯按 10001200px 排版,所以这两针补丁是必需的。
  • 原文只发给会话同款模型路由,不新增外部出口。

改补丁时注意:页面自己要用 position:fixed / position:sticky 放常驻控件(固定头、浮动图例、 返回顶部)。这类纯 CSS 语义离线桩测不出来(桩里没有布局引擎),所以除了代码形状断言之外, 另有一个真机检查 npm run check:patch(需要 Chrome,找不到就 SKIP)。

开发

git clone https://github.com/yfwu2020/dsh-reply-visual.git && cd dsh-reply-visual
npm install          # 装 devDependencies(构建需要的 @deepseek-ai/* 类型)
npm run build        # 探运行时 → 链 @deepseek-ai/@types → tsc → 拷 client
npm test             # 503 条断言,不联网、不花钱
npm run check:patch  # 7 条真机断言(需要 Chrome)
npm run watch        # 监听 src:改 client 立即重建(client-hmr 自动换掉运行中的插件)

目录:

src/index.ts                 host 半(Config / 6 条路由 / SSE / 提示词 / 校验 / 缓存 / 保存)
src/client/index.js          客户端半(手写 ModuleLoader bundle,无打包器)
skills/visual-page/SKILL.md  可视化规范:做什么 + 环境什么样(**运行时读取,改文案不用重新构建**)
skills/page-design/SKILL.md  审美参考:怎么做得好看(运行时读取,可替换/可关)
scripts/build.sh             探运行时 → 链 @deepseek-ai/@types → tsc → node --check → 拷 client
scripts/watch.sh             开发监听
scripts/test-*.mjs           离线测试(split / validate / route / llm / client)
scripts/check-display-patch.mjs  真机检查:fixed/sticky/横向溢出/橡皮筋(需要 Chrome)
scripts/gen-readme-shots.mjs 生成 README 截图(抠真实 CSS 渲染,见下)
PROMPTS.md                   提示词与调参笔记:发出去的是什么、为什么这么写、坑在哪

测试

脚本 断言 盯住什么
test-split.mjs 23 页面抽取与切分:标记跨 chunk、无标记、页前后废话、逐字符投喂、截到 </html>、标题抽取
test-validate.mjs 55 能不能渲染的每一档、原文兜底抽取、页面标题抽取(畸形 HTML / 实体 / 截断)、slug/越界防护
test-route.mjs 85 全链 / 缓存回放不调模型 / 在飞去重广播 / 限流 / 断开中止上游 / save 落盘 / /recent 带 pageTitle / 对话位置(客户端给的优先、没给回日志里查、脏值当没给、跨会话带会话标题且单会话失败被隔离)
test-llm.mjs 26 模型的 9 种"乱写"姿势:废话、截断、空输出、报错、外链、iframe…
test-client.mjs 335 注册(工厂 / apply / 4 个服务 / 动作行 / 会话头入口 / 侧栏 page key)/ 自画图标的几何与无障碍(外框、图元数、aria-label)/ 采集多步 turn(含"问题按 seq 找"的回归)/ 一条流只建一个 Blob / 沙箱不带 allow-same-origin / params 丢失恢复 / 历史浮层:按对话顺序排、会话分组、分组头标题与当前会话标记 / 翻页:对话正序、循环、不闪骨架屏 / 等待动画三段(含光标延迟出现、高度跟行、橙色)/ 样式表与渲染对得上(用了没定义、定义了没用)

合计 524 条,全部离线(不打网络、不调模型);另有 npm run check:patch 的 7 条真机断言。

调试钩子:window.__dshReplyVisual(collectReply / normalizeClientHtml / parseSseBlocks / originalCache / counters{blobs,iframes});host 侧 GET /reply-visual/api/ping 一眼看到 当前路由、档位、缓存条数、在飞请求数。

改 README 的截图

node scripts/gen-readme-shots.mjs            # 全部场景 → assets/*.png
node scripts/gen-readme-shots.mjs waiting    # 只出一张
DSH_CHROME=/path/to/chrome node scripts/gen-readme-shots.mjs

它不另写一套样式:从 src/client/index.js 里 injectCss() 的样式数组原样抠出 CSS (抠不到就报错,不静默退回),再按 render() / waitStage() 的真实 DOM 结构拼舞台。 所以样式漂移时图会跟着变,不会出现"图好看但和插件不一样"。

assets/demo/diagram.html 是 panel 场景里当演示内容的那一页(一张由本插件产出的真实图解)。

自定义:改规范,不改代码

模型的自由度是故意放开的,收窄或换风格都改这两份 Markdown,改完不用重新构建:

想要 改哪里
图更好看 / 更讲究 reasoningEffort: high(默认 low)
页面更长更全 maxTokens 调到 12000~16000(配合 timeoutMs)
更快出图 reasoningEffort: off;maxTokens 降到 5000
更收敛的风格(比如只允许某几种图) 直接改 skills/visual-page/SKILL.md——现在是"自由发挥"版
换一套审美 改 skills/page-design/SKILL.md,或把 designPath 指到你自己的 Markdown
觉得太"设计"了 designPath: off,只留主体规范
完全禁脚本 allowScripts: false(沙箱退化成 sandbox="",纯静态页面)
换语言 规范里补一句"页面语言跟随原文"
换模型 provider/model,或在设置里换「默认模型」

skills/visual-page/SKILL.md 只讲两件事:这一页要达成什么(让人一眼看懂),以及运行环境是什么样 (沙箱 iframe / 约 320~720px 宽 / 通常没有外网 / 读者手上永远有原文可对照)。 没有格式要求,没有必须遵守的规则 —— 形式、篇幅、配色、层级、要不要动效,都由模型自己决定。

skills/page-design/SKILL.md 讲"怎么做得好看",注入时明确写着"这是参考,不是规则"。 两份分开是有意的:可以单独关掉、换成你自己的审美文件,而不动主体规范。

审美参考的思路来自 Anthropic 公开的 frontend-design 技能 与社区反 slop 实践,按本插件场景逐条改写——那些规范面向品牌落地页,原样照搬会翻车 (沙箱无外网,引 Google Fonts / 动效库会静默回退)。

边界与失败行为

原则:不替模型决定"不给看"。除了"页面是空的"和"大到浏览器画不动(>16MB)", 一切都照常展示,有问题只在底部提示一句。

情况 行为
回复没有正文 / 还在生成 不渲染按钮
模型先写废话 / 不写 <<<PAGE 标记 页面照样被找到;页面前后的说明不会进 iframe
模型只写了说明、没给页面 明确原因 + 「重画」+「以原文显示」,不给空白页
输出被截断(没有 </html>) 照常展示已到达的内容 + 提示具体原因(达到模型上限被截断 / 超时或连接中断 / 模型提前结束)。不自动续写
页面里嵌了 <iframe> 照常展示 + 提示在沙箱里可能显示不出来
页面偏大(超 maxPageBytes) 照常展示 + 提示"滚动可能有点卡";只有到 16MB 才拒绝
外链 / @import / 标签未闭合 / 代码没保留 照常展示 + 黄条提示(逐条写清)
模型报错 / 无可用路由 / 触发限流 明确原因 + 「重画」「以原文显示」,绝不空面板
原文超限 头 60% + 尾 40% + 省略标记;页头「原文已截断」;「原文」档仍是本地全量
关闭标签 / 切会话 中止在飞的 SSE 与模型流;最后一个订阅者走了才 abort(不掐断别人)
浏览器刷新 回放该会话上一条;缓存过期则给 /recent 列表
保存 .html 取不到会话 cwd → 409;标题经 slug 净化 + 越界校验,只落在 <cwd>/reply-visual/

已知限制

  • 一个回复不是一张单独标签:原生页类型一个 pane 只能有一个同 kind 的页,所以是「随点随切」。 要多页并存需要注册 dsh-resource://reply-visual/** 协议 + resource provider。
  • 任意图由模型用内联 SVG 画,插件不内置 Mermaid 运行时(7MB 懒加载块 + 打包步骤); 需要更专业的图(时序/甘特)时模型仍然可以直接画。
  • 页面与插件主题不共用:模型页面自己带配色(规范要求深浅两套 + CSS 变量), 字号档通过 CSS zoom 缩放 iframe。
  • 历史列表一次最多 200 条:客户端要 limit=200,host 再按 maxCacheEntries 夹一次。 超过 200 页要搜"滚回那条回复再点「图解」",或调大 maxCacheEntries。
  • 侧栏宽度窄(320720px)时工具栏会换成 23 行,这是有意的取舍。

发布(维护者)

发布走 Trusted Publishing(GitHub OIDC),不需要任何长期 npm token:

# 1) 改 package.json 的 version(例如 0.2.0)
# 2) 打同名 annotated tag 推上去
git tag -a v0.2.0 -m "..." && git push origin v0.2.0

.github/workflows/publish.yml 会自动:校验 tag 与 version 一致 → npm install → 构建 → 跑全量测试 → 类型检查 → npm publish --provenance → 打 tgz 并建 GitHub Release。

npm 侧只需配一次:包设置 → Trusted Publisher → GitHub Actions, 填 yfwu2020 / dsh-reply-visual / publish.yml,并勾选允许 npm publish (默认只允许 npm stage publish)。

devDependencies 里的 @deepseek-ai/* 钉在 0.1.7-rc.2(与插件开发所依据的运行时一致)。 升级运行时时要同步改这里——用 latest 会编译失败(缺 WebServer 等 API)。

License

MIT