dsh-ui-block — 页面块(Page Block)
2026-10-24:触发方式换成独立一行
::dsh-ui <name>::。
- 新写法(消息里单独一行,前后可有空白,不带反引号、不在围栏里、不带别的字):
::dsh-ui 名字::或::dsh-ui 名字 res=820x510::。该行会被隐藏,画布插在它原位;res=决定画布尺寸,缺省 1440x900。- 旧写法(裸围栏 + 块体首行
dsh-ui ref: ui-blocks/<name>.uiblock)已彻底删除, 代码里没有任何兜底识别路径;scripts/check-meta.mjs、scripts/check-selectors.mjs随之删除。- 自检:
node self-check-uiblock-directive.mjs(当前 46 条断言,含"旧写法不命中"、 "代码块里不命中"、"非法 res 被拒",以及下一条的补丁链/时刻还原)。- 缩放、画布内选区、拖拽、读文件、报错处理未改。
2026-10-24 第二轮(用户拍板"就要和代码块一模一样"):外壳改回真·代码块—— 座位里用 React 挂载聊天自己的
CodeBlock(require('@deepseek-ai/dsh-client-ui-primitives')), 外框、底色、圆角、横栏、「复制」图标与 tooltip 全是同一个组件的同一份样式,不是仿制品。 横栏语言标签固定是dsh-ui(走toolbarLabels.codeLabel,不改写 DOM);复制按钮复制的是ui-blocks/<name>.uiblock(code传的就是路径,<pre>只是display:none藏起来)。 下面「围栏块 / 横栏钩子」记的是旧围栏实现的来龙去脉,DOM 钩子与现在这套一致。
2026-10-25:引用按消息时刻还原(写法一个字都没改)。
- 引用语法不变,仍是
::dsh-ui <name>::/::dsh-ui <name> res=WxH::,本页其它章节 讲的解析规则照旧。- 一条消息里的引用显示的是那条消息第一次渲染时这一版块;之后改块只影响之后的新消息。 刷新页面也还是各显示各的版本(钉子存在宿主侧的
ui-blocks/.pins.json,不在浏览器内存里)。- 历史是补丁链,不是副本:
ui-blocks/.history/<名字>/base.uiblock只有首次见到的那一份基线,log.json每行一条{at, patch}(行级 hunk,自己写的 Myers,无依赖)。- 宿主路由:
?at=<epoch毫秒>还原那一刻的内容;?pin=<对话节点键>&i=<第几条>首次请求钉住 "现在",之后沿用;两个都不带仍是最新(旧客户端兼容)。- 钉住的引用不再 1.5 秒重读(内容不会再变);读失败仍然重试。
- 找不到
[data-chat-node-key]祖先时(草稿预览、非对话场景)退回旧行为:不带 pin,显示最新。- 升级前已有的历史消息:首次渲染时才钉住,钉到的是"现在",所以它们仍显示最新——这是缺信息,补不回来。
- 自检:
node self-check-uiblock-directive.mjs覆盖补丁链还原(sha 对齐)、?at=取中间版本、 pin 幂等、以及"历史目录里没有整份副本"。
下面从「围栏块 / 横栏钩子」讲起的段落是已退役实现的历史记录(
.md-code-block三个钩子、A 路径"同壳替换"等),保留只为排查旧问题;当前代码不再从围栏里取内容, 但重新借用代码块组件当外壳(见上一段)。
v0.4.0(2026-10-10)画布内选区:横栏加一个选区按钮(分辨率按钮右边,只有 13×13 SVG 图标、无文字)。点亮后可在画布内部点选元素,选中的元素变成 element«…» chip 直接插进 composer。交互与浏览器选区插件 element-picker 对齐:鼠标移动高亮 / 滚轮切层 / ↑↓←→ 层导航 / 点击选中 / 再点一次图标退出。Enter 与 Esc 都不绑(选区不越界占用全局键)。
两份独立副本(2026-10-09 实测澄清):3080 的 profiles\web\node_modules\dsh-ui-block junction 指向 D:\dsh-plugins\dsh-ui-block;4000 的 junction 指向 D:\dsh-fallback\plugins\dsh-ui-block —— 后者是一个独立的真实目录(fsutil reparsepoint query 报 4390「不是重解析点」),两边不共享。改代码必须两处都同步,否则只有一边生效。改完 4000 重启即生效,3080 要用户自己重启。
A 路径:同壳替换(不碰任何聊天包)
用 MutationObserver 找到消息流里的围栏代码块 DOM,把内容节点换成 iframe,并往横栏右侧注入控件。没有任何包补丁要回滚,卸载 = 删插件目录 + 还原 profile 两处。
只依赖三个稳定钩子(每 1.5s 自愈一次)
| 钩子 | 来源 | 作用 |
|---|---|---|
.md-code-block |
CodeBlock 显式加的字面 class(不是哈希类名) |
找块 |
[data-code-block-content] |
primitives 里注明 "consumers may turn the stable content node into a viewport" | 内容节点 |
[data-code-block-banner] |
横栏 | 注入分辨率控件 + 选区按钮(缩放/写回控件已删;0.2.3 起隐藏外壳自动换行开关) |
node scripts/check-selectors.mjs 会把 client.js 里的 SEL_* 常量拿去 4000 自己的产物语料里对,缺一个就 exit 1。页面里另有运行时断言:window.__dshPageBlock.stats.selectorMiss(>0 说明钩子变了)。
尺寸模型(v2:地图视口,三个独立状态)
把块当成一扇看画布的窗户:窗宽由聊天栏决定,画布(iframe)在窗内做地图式变换。
- 画布分辨率:
res=1440x900声明,改它 = 改 iframe 真实的 CSS 视口 ⇒ 内容重新排版(media query 生效)。横栏控件,改法与 v1 相同。 - 缩放:光标悬在画布上直接滚轮,朝光标点缩放(newOffset = cursor − (cursor − offset) × newZ/oldZ),只加
transform: translate+scale⇒ 永不重排;钳制 20%–400%;双击画布复位(适应宽度一次)。横栏不显示缩放控件与百分比。 - 外框高度(窗户高度):
h=300声明默认值;缺省/非法回退min(画布高×初始适应, 480px)。拖下缘把手调整(120px–视口高),双击把手复位。横栏不显示高度。
画布比窗小 → 居中;比窗大 → 拖拽平移(grab 光标;拖拽/调高的 move+up 挂在 window 上,不怕光标出块、不怕聊天壳的浮层盖住画布),没有原生滚动条。iframe 自身 pointer-events:none,交互全由插件自有的透明窗体层接管。
触发与元信息(v0.3.0)
触发与常量同在围栏块体的第一行:第一行以字面 dsh-ui 开头,后面跟可选的空格分隔常量。正文 = 第二行起(dsh-ui 那行永远不进沙箱)。
dsh-ui res=1440x900 h=300 title="登录弹窗"
<div class="pb pb-center">…</div>
dsh-ui 单独一行 = 全部用默认(1440×900):
dsh-ui
<div class="pb pb-center">…</div>
未加引号的写法同样有效,多词值一路读到下一个 key=:
dsh-ui res=1440x900 h=760 title=首页 · 改版 (非对称 · 分级卡片)
<div class="pb pb-center">…</div>
- 触发:第一行匹配
/^dsh-ui(?:\s|$)/(trim()之后)。dsh-ui必须是完整的首词 ⇒dsh-uires=1、dsh-ui2不触发。 - 围栏语言无关:写
dsh-ui`、````(无语言)还是text完全一样(宿主只印它认得的语言名,dsh-ui` 会显示成"代码块"——见下)。 - 未加引号的多词值现在也能解析:值的边界是下一个合法键(
^[a-z][a-z0-9-]*=),所以res=1440x900 h=760 title=首页 · 改版 (非对称 · 分级卡片)解析为{res:"1440x900", h:"760", title:"首页 · 改版 (非对称 · 分级卡片)"}。引号形式title="a b"/title='a b'行为不变(引号值在闭合引号处结束,后面不再续接)。 - 坏 token 跳过,不再连坐:
·、改版、(非对称、空值的key=、未闭合引号这类 token 只被丢弃,不影响触发(触发只看dsh-ui前缀);被丢弃的只是那个常量的值。 - 只有
title的值会续接多词;res/h/width/height/zoom仍是单 token ⇒res=1440x900 随便 垃圾取1440x900、其余丢弃。未知键(x=1)保留在 meta 上但没人读。一个常量都没有(dsh-ui单独一行)也照常渲染,只是全用默认。 - 诊断(只进控制台,不影响渲染):迁移诊断 —— 第一行像旧 meta 行但没有
dsh-ui前缀时(looksLikeMetaLine()命中,或该行能解析出至少一个已知键),window.__dshPageBlock.stats.metaBail计数 +1、stats.lastMetaBail = {line, reason:'missing-dsh-ui-prefix'},并console.warn一行提示加前缀(同一条只 warn 一次,不随 1.5s 扫描刷屏)。这类块不渲染。普通散文/代码不打印任何东西。res非法不走这条路,一行也不打印。 res缺失或非法 ⇒ 同一条路(2026-10-04 契约变更,2026-10-09 复核仍成立):res=abc、res=default、res=1180、width=x与完全不写res等价,统一用默认 1440×900 正常渲染,不再退回普通代码块,也不加任何 UI 提示(用户原话:「就直接用那个默认的吧」)。旧版"声明了却解析不出来 ⇒ 整块退普通代码块"的resBails()闸门已删除,那条硬契约作废。h缺省或非法(h=abc)⇒ 不炸块,回退高度 min(画布高×初始适应, 480px)。- 缺
res(或res非法,同上)但有其它键 ⇒ 默认 1440×900,横栏沿用既有那条"未声明,使用默认"的显示;非法值不另做文案。 html别名:v0.3.0 已删除(语料实测 0 次使用)。现在 ```html+` 的普通 HTML 示例不渲染。- 旧写法(首行没有
dsh-ui前缀)一律不渲染,只留一条missing-dsh-ui-prefixwarn。
画布内选区(v0.4.0)
按钮
分辨率按钮右边,只有 SVG 图标、无文字,13×13,箭头形状 M2.5 2.5l5 11 1.8-4.2 4.2-1.8z。
| 项 | 未点亮 | 点亮 |
|---|---|---|
| 内容 | 只有 SVG | 同 |
| 图标尺寸 | 13×13 | 同 |
| 底色 | 透明 | 透明(不变) |
| 边框颜色 | var(--dsw-alias-border-l2) |
#22d3ee(青蓝) |
| 线条颜色 | var(--dsw-alias-label-secondary) |
#22d3ee(青蓝) |
| 线宽 | 1.3 | 1.5 |
title |
在画布内选取元素 | 同 |
点亮 = 图标线条 + 圆角边框都变青蓝,底色仍透明(整个按钮的"线"高亮,不加实心背景)。
⚠️ 不要用
var(--dsw-alias-brand-primary)做高亮色。实测它映射到--dsw-static-neutral-bluish-*(灰蓝色),深色主题下观感接近白色 —— 这就是"高亮像白色"的原因。要真青蓝只能写死色值,本插件用#22d3ee。
交互(与 element-picker 同一套心智)
| 操作 | 行为 |
|---|---|
| 鼠标移动 | 高亮指针下的元素(蓝框) |
| 滚轮 | 在层叠栈里上下切换(穿透遮挡,选到被盖住的元素);已 preventDefault,不会同时触发画布缩放 |
| ↑ | 选父元素 |
| ↓ | 选第一个子元素 |
| ← / → | 选前/后兄弟 |
| 点击 | 确认选中 → token 直接进 composer |
| Enter | 不绑(无意义,留给页面) |
| Esc | 不绑(交给上层) |
| 结束选区 | 再点一次选区图标 |
层叠栈用 doc.elementsFromPoint(x, y)(iframe 内部的 doc),并按 element-picker 的写法补上 pointer-events:none 子树漏掉的祖先。
方向键前提:keydown 绑在 iframe 的 contentWindow 上,所以点亮时插件会 frame.focus({preventScroll:true})、并在 pointerdown 里补一次 win.focus()(preventDefault 顺带压掉了本该转移焦点的兼容 mousedown)。焦点在画布内时方向键才收得到。
一次只属于一个块:点亮第二个块的选区会自动熄灭第一个(模块级 armedPick),同页多个页面块不会互相抢指针。
插入路径(乙:直接插 composer,不走剪贴板)
- 注册一个
conversation.input.overlay座位,iddsh-ui-block-picker(与 dsh-element-chip 的dsh-element-chip不同;该座位是 additive list,两个共存),组件返回null,只用来接住props.sessionId与props.inputActions。 - 选中时取
inputActions.captureInsertion()拿带draftRev的插入 span,然后emit(sessionId, 'slash/input-insert-reference', { reference: {source:'element', ref:token, label:tag, clipboardText:token}, span })。 source复用'element'—— chip 的渲染、序列化、复制全部复用 dsh-element-chip 的既有实现,零重复。- 座位注册走
ctx.get('slots')(不是ctx.slots),并在 1.5s sweep 里惰性重试:slots 服务缺席时页面块照常工作,只是选区会提示"插入失败",不会因为缺服务整块停摆。
token(3 行,复用 element-picker 格式与截断规则)
element«DOM Path: #lbtn
Position: top=0px, left=0px, width=72px, height=27px
HTML Element: <button id="lbtn" class="cta" data-role="primary">实时按钮</button>»
- 选择器:有
#id用#id,否则最多 4 层tag.class:nth-of-type。 - 位置:
getBoundingClientRect(),在画布自己的坐标系里(即res=声明的那个逻辑视口)。 - HTML 行:只印元素自己的属性(≤8,多出的记
... +N more)与自己的文本(≤80 字符),子节点标记不展开。 «/»/ 反斜杠按 element-picker 的规则转义,保证第一个裸闭合书名号永远是 token 结尾。- Styles 行已删(与 element-picker 同步,token 现在是 3 行)。
沙箱(v0.4.0 变更)
sandbox="allow-same-origin":只有allow-same-origin,绝不加allow-scripts(两者同时 = 沙箱失效,规范明示)。- 为什么加:画布选区要读 iframe 内部的 DOM(
contentDocument+elementsFromPoint);没有同源就是null,选区做不了。 - 代价:父页面能访问草稿 DOM(草稿能用 CSS 做视觉欺骗,但它本来就能)。
- 没有放开的:
<script>仍不执行(window.__ran === undefined,实测);CSP 仍生效(外链<img>naturalWidth === 0,实测);iframe 读父页面仍失败。三条都在tmp\dshui-canvas-picker-20261010的验证里逐条断言过。
- 为什么加:画布选区要读 iframe 内部的 DOM(
- 旧值
sandbox=""(v0.3.0 及以前)已不再使用。 - srcdoc 里 CSP
default-src none; style-src unsafe-inline; img-src data: blob:; font-src data:; media-src data:; form-action none; base-uri none,referrerpolicy="no-referrer"。 - 主题令牌:读宿主
document.body/documentElement上的全部--dsw-*(0.1.7 时 384 个,0.2.0-rc.2 实测 413 个)注入 srcdoc 的:root,并写color-scheme。主题切换后 1.5s 内自动重绘(已验证:不刷新页面,深→浅会自己重绘)。
安装到 4000
文件清单:
D:\dsh-fallback\plugins\dsh-ui-block\package.jsonD:\dsh-fallback\plugins\dsh-ui-block\cordis.patch.ymlD:\dsh-fallback\plugins\dsh-ui-block\index.js(宿主半边,空 apply)D:\dsh-fallback\plugins\dsh-ui-block\client.js(全部实现)D:\dsh-fallback\plugins\dsh-ui-block\scripts\check-selectors.mjsD:\dsh-fallback\plugins\dsh-ui-block\scripts\check-meta.mjs(v3:把 client.js 的纯解析区抽进沙箱,50 断言盯dsh-ui触发 +res=/h=/title=契约,含首词边界、正文剥离、未加引号多词 title、坏 token 跳过,以及 2026-10-04 契约res缺失/非法 ⇒ 默认 1440×900 不退块 + "旧 metaTrigger/htmlTrigger 已删除"的结构断言)D:\dsh-fallback\plugins\dsh-ui-block\README.md
步骤:
- profile 的
package.json加依赖"dsh-ui-block": "link:D:/dsh-fallback/plugins/dsh-ui-block",并把dsh-ui-block追加进dsh.profile.bundles。 - 在
D:\dsh-fallback\home\profiles\web跑pnpm install --config.auto-install-peers=false --store-dir D:\dsh-fallback\pnpm-store。 - 重启 4000(client bundle 图只在启动时合成):按端口杀
Get-NetTCPConnection -State Listen -LocalPort 4000的 pid,再Start-Process cmd.exe -ArgumentList /c,D:\dsh-fallback\start.cmd -WindowStyle Hidden。 - 浏览器刷新页面(URL 见启动日志最后一行)。
⚠️ 改完 profile package.json 后、pnpm install 之前,运行中的实例会热加载 manifest 并打印两条 skipping profile bundle "dsh-ui-block" —— 那是装的先后顺序,不是失败;重启后就没了。
卸载 / 回滚
- 从 profile
package.json删依赖与 bundles 行(同目录.bak-pageblock-20260928是原样备份)。 pnpm install(同上)。- 重启 4000。插件目录可留可删。
与计划的冲突(照实说明)
计划写的是条目行元信息:dsh-ui res=1440x900 title=…。这在本版做不到,证据:
dsh-client-ui-primitives/lib/index.js的renderCode()只把/^[\w-]+/.exec(info)(第一段)当lang传给CodeBlock,其余 token 直接丢弃;- 横栏标签走 shell 产物里的
I6(lang) ? lang : labels.codeLabel,I6是产物自带的语言表(含html、ts、mermaid…,不含dsh-ui),所以dsh-ui落到codeLabel; - 2026-10-24 起直接把
toolbarLabels.codeLabel传成dsh-ui,于是横栏左边就印dsh-ui(字号/颜色/位置与bash这类真语言名完全一致,实测 dx=22、dy=13 相同),不需要改写 DOM。
替代做法(本次实现):元信息写进块体第一行(仍是纯文本、可 diff、可复用)。升级路径:若要条目行形式,需要走 B 路径(读会话事件的原始 markdown / ctx.sessions)或 C 路径(改聊天包),另议。
已知上限(ponytail 记录)
旧块全部失效(v0.3.0 的既定代价):首行没有
dsh-ui前缀的历史块在重启后不再渲染,只留一条 warn。用户已接受(插件在测试期)。依赖 DOM 钩子:聊天包升级换钩子会失效 → 由
scripts/check-selectors.mjs+stats.selectorMiss兜住,不静默坏掉。React 重渲染(点复制/换行按钮)可能把我注入的节点摘掉 → 1.5s 常驻 sweep + MutationObserver 自愈;用户改过的分辨率/缩放存在
WeakMap里不会丢。块被改写后不再触发时,已渲染的宿主不会自动拆除(只有内容变化的块才重解析)。升级路径:在
ensure()的早退分支里比对 textKey 并 teardown。res写错不再报错(2026-10-04 用户决定):res=1180这类漏写尺寸的值会安静地按默认 1440×900 渲染,界面无提示、控制台不打印,stats.metaBail也不计(它只统计"像旧 meta 行但缺dsh-ui前缀"那种)。想知道生效分辨率只能看横栏上那个数字。升级路径:要恢复"写错就退普通块",把删掉的resBails()判定加回ensure()即可。多词续接只认
title:其它键(含未知键)的值仍是单个 token,所以zoom=之类的多词值用不上;要放开就改parseMetaLine()里那行runOn判断。画布上的点击被窗体层吞掉:iframe 默认
pointer-events:none(沙箱无脚本,静态稿无感)。v0.4.0 选区点亮时会临时改成auto,退出时还原 —— 所以选区模式下的点击被选区接管,退出后仍由窗体层吞掉。阶段 L 上真交互时要改成转发点击(client.js 里 ponytail 注释标了位置)。滚轮被劫持:光标在画布上时滚轮属于缩放(用户已接受);滚聊天页移出块即可。选区点亮时滚轮改为切层(
preventDefault,不会同时缩放)—— 这是刻意的,见上。选区焦点:点亮选区会把焦点移进 iframe(方向键需要);再点图标退出后焦点不主动还回 composer。
选区依赖同源:
sandbox去掉allow-same-origin会让选区整体失效(contentDocument变null,会提示"画布还没准备好")。计划卡片内的块走同一套 primitives,预期同样生效,但本次未单独验证(试点范围是聊天消息)。
交互回流(点击经 HTTP 桥
agent.steer)是计划的阶段 L,本次未做。沙箱自 v0.4.0 起是allow-same-origin(见「沙箱」节)。
No comments yet. Be the first to write one.