这份文档保留 dsh-drawai 为什么这么做 的全部细节:载体为什么是 .drawio、
无损写回的外科手术规则、drawio 的 style 键与默认省略、图层与折点模型、
连线路由与预览、构建器为什么这么小、以及宿主/客户端两半边各自的自测。
面向使用者的说明在 README;发布与收录流程在 release.md。
文中「这一版刚补齐,留档」之类的段落是开发过程中的记录,保留原样 —— 它们记的是 当时为什么改,不是当前待办。
已实现
| 半边 | 能力 |
|---|---|
| 宿主 | diagram_apply:结构化 ops(addNode/addEdge/setLabel/setStyle/move/remove/highlight)→ 分层自动布局 → 原子写回。返回值里带 created/changed/removed(结构化 id,含自动分配的那些),模型不必解析 summary 文本 |
| 宿主 | 一次改一组:setStyle/setLabel/move/remove 都收 ids:[…](用户说"把这几个换成绿色"时不用发 N 个 op);addNode/addEdge 收 as:"名字" 别名,同一个 ops 数组里就能引用"刚建的那个"(不必猜自动分配的 n7) |
| 宿主 | 改接边而不重画:{op:'setEdge', id, from?, to?, fromPoint?, toPoint?} 只换 source/target(或把某一端脱开成自由点),边的 id、标签、标签位置、折点、端点约束全都留着 —— 以前只能 remove+addEdge,那些一次性全丢 |
| 宿主 | 边上的文字位置可读可写:diagram_read 回 labelX/labelY(沿边比例 + 垂距;拖过才有),{op:'setLabelPos', id, dy:12} 挪一下、{center:true} 放回弧长中点(= 画布右键「标签居中」) |
| 宿主 | 顺序(谁压谁):{op:'order', id|ids, to:'front'|'back'|'up'|'down'},与画布右键同一套语义,并且真的改文件里单元的先后(否则重新打开又变回去) |
| 宿主 | 复制:{op:'duplicate', id|ids, …} 新 id + 整体平移两格(与 Ctrl+V 一样),折点/端点约束/标签位置/自定义数据都带过去,默认连"两端都在复制范围内的边"一起复制 |
| 宿主 | 按层操作:{op:'addLayer'} 新建层、addNode/addEdge/duplicate 的 layer(层 id 或层名)、{op:'setLayer', id|ids, layer} 移层、{op:'setLayerProps', layer, name?, visible?, locked?} 改名/显示/锁定。移层是真的改文件里的 parent(以前模型里改了层名却写不回去——见下面那条注记) |
| 宿主 | 导出通道:{op:'export', format:'svg'|'png'} —— 渲染器只在浏览器那一半,所以这是"请求/回执":宿主挂请求 → 客户端下一次轮询取走并渲染 → svg 回执给宿主落在 .drawio 旁边、png 走浏览器下载;diagram_read 的 export.status 是 pending/done/downloaded/failed |
| 宿主 | 位图矢量化:{op:'traceImage', image:'ref.png', x, y} —— 宿主真的读那张图的像素(PNG 走自带解码器、零依赖;JPEG/GIF/WebP 需 sharp),跑 泛洪去背景 → k-means 定调色板 → 3× 超采样归类 + 众数滤波 → 按颜色分 4 连通域、沿像素格边取闭合轮廓(区域恒在左侧的左转跟随)→ RDP 简化 + 相对增量编码,最后每色一条 nonzero <path> 建成 svg 内容节点。默认 1:1(viewBox = 原图像素系、w/h = 图像尺寸)。可调 colors/grid/scale/epsilon/minArea;summary 回报色数/路径数/环数/字符数与像素覆盖是否精确。核心在 src/trace-image.js(命令行 tools/vectorize-image.mjs 与它是同一份,不重复实现) |
| 宿主 | 容器层级可读:diagram_read 的节点带 parent(它在哪个容器里)。画布按绝对位置显示,所以移动容器不会带走子单元 —— 要整组动就把它们一起写进 move 的 ids(以前 AI 根本不知道有这层关系) |
| 宿主 | 「看一眼」画布效果图(工作区零文件):diagram_read {render:true} → 画布把当前画面渲成 PNG 回执给宿主 → 宿主存进 DSH 的附件服务(attachments.saveImage,内容寻址、在工作区之外,与用户上传图片是同一套存储)→ 下一次 diagram_read {render:true} 用一个 {type:'image', attachment} 内容块把图交给模型。一个字节都不写工作区、也不进下载目录。状态在 look 里:pending(已请求,画布几秒内渲好)/ ready(图在这一次的返回里,stale:true 表示回执之后文件又被改过)/ unsupported(这个部署没挂附件服务、或当前模型没声明图片输入 —— 给一句人话,其余功能照旧)/ failed |
| 宿主 | 乐观锁对 AI 也生效:diagram_apply 收 expectRevision(就是 diagram_read 回的 revision)—— 对不上(用户在 drawio 里改过)就报错且一个字节都不写,重读再改。不传则不检查 |
| 宿主 | diagram_read:读回节点/边/标签/style 键(并给出派生的形状/线型/箭头/颜色名便于阅读)、每个单元属于哪一层、图层表(名字 + 可见性 + 锁定) |
| 宿主 | 用户选区可见:客户端把"选中了哪几个单元"上报给宿主(action:'selection',防抖 300ms),diagram_read 用 selection 回给模型 —— 用户说"把这几个挪一下"时 AI 不用靠坐标猜。按文件配对(两个文件里都叫 n1 很常见)、按存在过滤,改图删掉的单元会主动划掉(id 会复用,不划掉就会"继承"旧选区) |
| 宿主 | 语义校验:边指向不存在的节点直接报错并列出已知 id,写盘之前失败 |
| 宿主 | 稳定 id 分配(n1… / e1…)、按字符宽度估算节点尺寸 |
| 宿主 | 自动布局四种:dagre-tb(默认,贴合右栏窄高形状)/ dagre-lr / grid / none;显式重排会清掉端点已移动的边上的过期折点 |
| 宿主 | 树/森林按"父节点居中于两个孩子"摆(二叉树、组织架构图、决策树):分层 → 重心排序减交叉 → 逐层居中,最后再自下而上把每个父节点摆到孩子跨度的中点,同层挤了只往右让。多父的 DAG(依赖图)保持逐层居中 —— 那里"居中于父"没有唯一答案。实测过:不做这一步,一棵完整二叉树会排成 B 压在 E 正上方、D 甩在左边 |
| 宿主 | 载体就是 .drawio:读写 drawio 的 mxfile 本身(含压缩形态)。revision 是文件内容指纹,所以 drawio 或别的编辑器改过文件,乐观锁照样准 |
| 宿主 | 无损写回(见下一节):只在原文件上改我们拥有的单元,其余原文逐字节保留;打开后原样保存 = 文件一个字节都不变 |
| 客户端 | 右栏 tab 类型 drawai:diagram(kind: diagram),认领 **/*.drawio |
| 客户端 | 文档由宿主读(action: 'read'):浏览器没有 zlib,drawio 压过的 mxfile 客户端解不开,所以解析器只有宿主那一份 |
| 客户端 | 工作栏在标签页之上:文件 / 编辑 / 图层 / 视图 / 导出 常驻在面板最上方,下面才是多画布标签条,再下面是画布。一张画布都没有时它也在(否则「新建/打开」就点不到了) |
| 客户端 | 下拉栏点外面就收:工作栏那五个下拉菜单与「打开 / 新建画布 / 另存为」面板,在外面按一下(画布、标签条、工作栏的空白处)就关掉,Esc 同样收掉 —— 菜单是"看一眼、点一下就完事"的东西,不收会一直挂在画布上方挡着,而且下次点同一个按钮会变成"关掉"而不是"打开"。两类地方不算外面:菜单自己(否则点选项会先把自己关掉),以及开菜单的那个按钮(它自己有 toggle 语义:点同一个是关、点另一个是换)。判定挂在 .drawai-root 的捕获阶段,所以在画布自己的按下处理(框选、拖动、右键菜单)之前就判完 |
| 客户端 | 打开后为空:进入面板时不预先开任何画布(没有"未绑定画布"这种中间态),用「文件 → 新建画布…」或「文件 → 打开…」开始;关掉最后一个标签就回到这个空舞台 |
| 客户端 | 「打开…」列出的每一个 .drawio 都能直接打开;文件里"画布表示不了、但会原样保留"的东西(多页、分组层级、图片、HTML 标签)在工作栏上一句句说明;图层已经能显示(见下面的图层一节),所以它单独报一句"这个文件有几个层" |
| 客户端 | draw.io 经典外观:11 种形状(由 shape=/ellipse/rhombus/rounded=/arcSize=/image= 驱动)、8 色 mxGraph 调色板(fillColor/strokeColor)、白纸 + 网格、正交折线 + 障碍避让、边标签衬底、自动换行、明暗切换 |
| 双方 | SVG 图形节点(形状枚举里的 svg):节点的内容是一段 SVG 标记,用来画形状词汇表里没有的东西(图标 / logo / 示意图)。落盘刻意用 drawio 自己那一套 —— shape=image;imageAspect=0;image=data:image/svg+xml,<encodeURIComponent(标记)>(依据:mxGraph.postProcessCellStyle 认的就是 data:image/svg+xml,%3C… 这种百分号编码形态,mxImageShape.apply 的 preserveImageAspect = getNumber(style, STYLE_IMAGE_ASPECT, 1) == 1,而 drawio 插入图片时写的是 imageAspect=0)—— 所以同一份文件在 drawio 桌面版里也画得出来,不是本画布私有的写法。选百分号编码而不是 base64 是因为编码后的串里一个分号都没有,而 style 解析按 ; 分段(;base64, 会踩这个雷;反过来,本轮也顺手把解析器修成能正确合并 …;base64,… 续行 —— 以前任何一次 setStyle 都会把内嵌 PNG 写成 …;base64,iVBOR…=1;,那张图就没了)。画布把标记喂给 <image href>(xlink:href 也写一份,导出到别处也看得见);<image> 里的 SVG 是独立文档:脚本不执行、外部资源不加载,所以不存在"把任意标记内联进页面 DOM"的风险(代码里没有任何 dangerouslySetInnerHTML)。imageAspect=0(缺省)= 拉伸铺满节点框、=1 = 等比缩放居中(preserveAspectRatio 跟着走)。缺 xmlns 时宿主动手补上 —— 不补的话浏览器按 XML 解析会整份丢弃那份图(屏幕上什么都没有,且不报错)。AI 侧是 addNode {shape:"svg", svg:"<svg viewBox…>…</svg>"}(不带字时可以省 label,缺省尺寸 80×80 正方形)、setStyle {id, svg:"…"} 换图、{id, svg:null} 清掉回到矩形;diagram_read 把标记放在节点的 svg 字段里,而 style 串里的 image= 省略成 image=«svg N 字符»(否则一次读取会被 data URI 撑爆)—— 带这个标记的 style 写回来会被拦下(照抄回来会让那一格变成"什么都没有") |
| 客户端 | 拖拽连线预览:从四面的引出端点拖出线时,实时显示与落盘同一套路由算出的正交折线(不是直线);靠近目标节点时列出它的四个端点并高亮将连接的那个(可挪指针改选),折线终点贴到该端点 |
| 客户端 | 多选对齐 / 分布:Shift 加选后右键 → 左/中/右、顶/中/底对齐,水平/垂直等距 |
| 客户端 | 空白左键拖框选:框到节点或线段都算选中(连线按段判定,不是拿整条边的外接框);Shift 框选是加选。选中的连线变蓝加粗并长出端点/段把手,可以直接 Delete 或整体复制 |
| 客户端 | 整体拖动:拖选区时节点、连线的折点与悬空端的自由端点用同一个位移一起走 —— 相对位置一点不变(以前只挪节点坐标,线被重新自动路由,形状就变了)。位移只吸附一次(拿抓起来的那个节点当基准),所以第二个节点与第一个之间原有的错位不会被各自吸附吃掉。抓在节点上或抓在线段上都能拖动整组;没有任何折点/自由端点的纯自动边没有"自己的几何"可平移,改形状要用段把手 |
| 客户端 | 对齐辅助线(drawio 的 guides):拖节点时与没在拖的节点比 左/中/右、上/中/下 六条线,差在 6px 内就吸过去并画出蓝色虚线(以整组的外接框为准);网格吸附仍是底子,辅助线在容差内优先 |
| 客户端 | Ctrl+A 全选(「编辑 → 全选」同款):节点与连线都进选区 —— 与框选同一套语义,于是能整体拖动、整体换样式、一把 Delete |
| 客户端 | 批量改样式:右键点中的元素在选区里时,形状/配色/线型/箭头作用于整组同类型元素(菜单标题会写"共 N 个/条");删除、拖动、复制、对齐早就是整组生效的 |
| 客户端 | 独立线:右键空白处「/ 独立连线」直接放一条两端都不接节点的线(drawio 的合法形态:两端都是自由点),之后拖端点就能接到节点上;它也能被整体拖动、加标签、改画法 |
| 双方 | 连线画法:线型(实线/虚线/点线,含 dashPattern 间距)× 箭头(单向/双向/无/反向)× 颜色 × 折角圆滑 × 引出段长度(jettySize)—— 全部是文档里的 drawio style 键,与 drawio 同构 |
| 双方 | 线型四选一:直线(edgeStyle=none,不带折点)/ 直角折线(正交,缺省)/ 圆角折线(rounded=1,只在折点处倒圆角 —— 圆角半径跟 drawio 一样由 arcSize 决定,缺省 LINE_ARCSIZE(20)/2 = 10px)/ 曲线(curved=1,drawio 的 Curved)。这是把 drawio 的两组键(走线 Straight |
| 双方 | 字号可改:节点标签、独立文字、连线上的文字都吃 drawio 的 fontSize 键。右键菜单里是「10 / 12 / 14 / 18 / 24」加上手动调节(− / 数字输入框 / +,8–72 任意整数,回车或失焦生效,打字过程中不写文档)—— 没有「默认」那一项:它唯一的作用是删掉键,而看到的样子和"选中那个等价档位"完全一样(节点/文字 12、连线 10,两个都在档位里),一个"选了等于没选"的选项只会让人犹豫;AI 侧是 fontSize:18(null = 删键,这条仍然保留) |
| 双方 | 字色可改:节点标签、独立文字、连线上的文字都吃 drawio 的 fontColor 键。右键菜单里节点与连线各有一类「字色」,色板预览的就是文字色(调色板的 stroke 那一支 —— 与"独立文字换色"是同一个值,否则点"黄"得到的字色看着像点错了);色板第一块是黑(写出去是删掉 fontColor 键 —— 画出来就是缺省黑字;叫它「黑」而不是「默认」:一排颜色里混一个"默认",既说不清它是什么色,又和"选个深色"是同一件事)。独立文字那一类直接叫「字色」(它只有字色可换,不再多一个重复入口)。AI 侧是 fontColor:"#666666",或调色板名(fontColor:"red" → #b85450),null = 删键 —— 注意它与连线上的 color 是两件事:color 是线的颜色,fontColor 是字的颜色 |
| 客户端 | 暗色外观不改节点里的字色:缺省字色由这块填充算(浅底黑字、深底浅字),不跟主题走 —— 以前缺省字色取的是主题的 skin.text(暗色 #e8e8e8),而填充是文档里写死的浅色(#ffe6cc/#dae8fc…,主题不改它),于是切到暗色,浅底节点上的字一起变浅,直接看不见。现在只有"填充也是主题补的深色底"(无填充节点)才用浅字,那一对仍然看得清;边上的字压在页面上,照旧跟主题走(深色页面配浅字) |
| 客户端 | 刷新:宿主变更流 remote.workspaceFiles.changes() 为主 + 5s 低频轮询兜底 |
| 客户端 | 视口不随容器尺寸变化:拖右栏改宽度时保持缩放比例、只改变可见范围(resizeViewFor)—— 否则右栏一拖画面就跟着缩放,且视口宽高比与容器失配后会被 preserveAspectRatio="none" 拉扁 |
| 客户端 | 自环:把线拖回起点节点自己即可(进出口自动错开,同侧会原路折回、看不见);复制 / 粘贴 / 剪切(Ctrl+C/X/V,剪贴板跨标签页,只搬两端都在选区内的边) |
| 客户端 | 拖到空白处 = 悬空端:新建连线落在空白处会连出一条"还没接上"的线;端点拖到空处就脱离形状(数据层用 targetPoint/sourcePoint,与 drawio 一致),再拖回节点上就重新接上 |
| 客户端 | 图层(v1):文件里的图层就是 drawio 的图层单元(root 下没有几何的 <mxCell>,value = 名字、visible="0" = 隐藏、locked="1" = 锁定),画布按它分层显示。「图层」菜单里列出现有层:点名字 = 设为当前层(新建的节点/连线/粘贴的单元都落进它)、点眼睛 = 显示/隐藏(写进文件,drawio 打开也是隐藏的;隐藏层的单元一个字节都不动 —— 隐藏是显示状态,不是删除)、「+ 新建图层」追加一层(自动起名 图层 N,id 避开已用的)。单元格的 parent 就写在它那一层上 |
| 客户端 | 右侧常驻元素条(飞书侧栏语义,条在右边):主条只放族入口(图形 / 文字 / 分区 / SVG);矩形·圆角·菱形等是「图形」同族变体,在侧面板展开,不铺满主条。放置为单次(点画布落下即卸工具)。悬停/选中图形时可换同族;文字/分区/SVG 不算同族。右键只留配色/字号/线型等与动作。菜单夹 maxWidth |
| 客户端 | 顺序:节点/边的右键菜单有「置顶 / 上移 / 下移 / 置底」;模型顺序决定覆盖顺序,写回会真的改动文件里单元的先后 |
| 客户端 | 编辑数据:右键「编辑数据…」按 key=value 一行一项改 drawio 用户对象的自定义属性;裸单元第一次加数据会自动包成 <object>(不包的话 drawio 保存时会把属性丢掉) |
| 客户端 | 线上的文字(边自己的 value):双击线、线上的文字、或线上的把手都能就地改(右键也有「改标签」;AI 侧是 addEdge {label} / setLabel);按住线上的字就能拖且有吸附(半格 + 贴线),位置按 drawio 的存法写进边几何的 x/y/offset(右键「标签居中」放回中点);落点缺省是 drawio 的弧长中点;线在文字的位置断开(真的不画那一段,不盖白底,labelBackgroundColor 有才画底衬) |
| 客户端 | 双击节点 = 就地改标签(右键也有「改标签」;AI 侧是 setLabel):编辑框是绝对定位盖在节点上的 HTML input(不用 SVG foreignObject —— React 会把 svg 后代建成 SVG 命名空间元素,里面的 input 不按 HTML 渲染),回车提交、Esc 取消、失焦也算提交;点编辑框外面(画布/标签条/别的元素)就提交并退出 —— 不能指望 onBlur,画布的按下处理普遍 preventDefault(),而它会挡掉焦点变化,输入框根本不 blur |
| 客户端 | 独立文字(drawio 的 text 形状):右侧元素条点「文字」后在画布落下一段没有边框、没有底色的字;放下即进编辑态(drawio 的 insertText 也是这样)。它就是一个节点,所以拖动/缩放/改字/改字色/进图层/复制粘贴/顺序/避让全都免费复用节点那一套;换族(文字↔矩形)走元素条时会武装放置而不是硬换。文字元素的"配色"落到 fontColor 上。样式与 drawio 的 Editor.defaultTextStyle 对齐 |
| 客户端 | pointer capture 只在"真的拖动起来"(>3px)时才抢:capture 会把随后的 click/dblclick 目标改成捕获元素(w3c/pointerevents#356:click 取按下/松开两个目标的公共祖先,而被捕获的 pointerup 目标是画布容器),按下就抢的话"双击节点/线上的字改标签"会整个失灵。所以按下只登记坐标,指针移动超过 3px(与"越 3px 才算拖"同一套手感)才在 pointermove 里抢 —— 一次点击全程不抢捕获。代价:极快地一甩、第一帧就离开面板的那种拖动可能抓不住(那条拖动本来也要靠 capture 才留得住),这是为了不牺牲双击而付的 |
| 客户端 | 读 drawio 的独立边标签单元(edgeLabel):按 mxGraphView.getPoint 的规则算位置(沿边比例 + 垂直偏移 + 残余偏移)并只读显示 |
| 客户端 | 走线里不会出现折返段(出去又原路描回来):选 L 时避开折返,残余的(目标侧桩点与折点分居拐点两侧时)由 removeRetraces 消掉 —— 人摆的折点当尖点时不动 |
| 客户端 | 快捷键有归属判定:焦点在输入框(DSH 的输入框是 Lexical 的 contenteditable,事件目标常常还是它内部的 span)、或别的面板的控件上时,画布一个键都不碰 —— 否则在输入框里按退格会删掉画布里的选中内容,Ctrl+C/V 也会被 preventDefault 吞掉 |
| 客户端 | 一次性几何迁移:菜单「整理几何(吸附到格线)」把这张画布对齐到格线;node tools/snap-geometry.mjs [--write] 面向盘上已有文件做一次性迁移 |
已补齐(编辑器路线图):图层 UI v2(锁定 / 重命名 / 删层合并 / 选中移层)、方向键微移、
查找跳转、导出选中、分区(右侧元素条可插入的区块容器,拖入/拖出挂 parent,非编辑菜单成组)、
右侧常驻元素条(放置 + 同族换形)、
边与节点按 drawOrder 混合叠放、多页切换、本机偏好(网格 / 默认形状配色)、AI page 参数。
未实现(用户侧):多页的新建页、.drawio.svg/.png/.html 内嵌载体;
swimlane 标题区/折叠等复杂容器布局。
未实现(交互细节):
- 拖拽时的对齐辅助线只跟节点比:不与连线的折点/端点对齐(drawio 的 guides 同样只看单元)。
- 单条选中的连线不能复制:剪贴板只收"两端都在选区内"的边(设计如此, 否则粘出来的边会指向没被粘贴的节点)。
- 导出只有 SVG 与 PNG(2×);没有 PDF。
AI 侧(这一版刚补齐,留档):diagram_read 现在回节点坐标(x/y/w/h);
新增 {op:'move'}(节点 dx/dy 或 x/y、连线 dx/dy 平移自己的几何)、
addEdge 支持 fromPoint/toPoint(两端都给点就是独立线)、
{op:'highlight', ids}(让画布选中给你看,不改文档也不再重排);
宿主留了一层"AI 写盘前的文本",界面「编辑 → 撤销 AI 改动」可退回(read 的 canRevert 表示有没有)。
另外:ops 里自带几何(addNode 给 x/y、或有 move)时默认不再重排 ——
否则"挪 40px"会被布局立刻冲掉,看起来像工具坏了。
图层方面 diagram_read 现在会回图层表(顺序 = 叠放顺序、visible、locked)和每个单元的
layer,所以 AI 知道"这东西在哪一层""哪一层用户现在看不见"(隐藏层的单元照旧读得到 ——
隐藏 ≠ 删除)。按层增删/移动单元的 ops 要等 v3。
AI 侧 P0(这一版刚补齐,留档):四件事一起做的,它们把 AI 从"猜"变成"知道":
| 做了什么 | 为什么 |
|---|---|
选区通道:客户端 action:'selection' 上报(防抖 300ms,框选时指针每动一下都会改选区),diagram_read 用 selection 回给模型 |
用户说"把这几个挪一下/换个颜色"时,"这几个"原来是看不见的,只能靠坐标猜 —— 这是 AI 侧最容易咬人的一条 |
结构化返回 created/changed/removed |
自动分配的 id 以前只出现在 summary 文本里,模型要用它就得解析自然语言(或者去猜 n7) |
ids:[…] 批量 + as 别名 |
"把这几个换成绿色"不必发 12 个 op;{op:'addNode', as:'start'} 之后同一个 ops 数组里就能 from:'start' —— 非空画布上猜编号必错 |
expectRevision |
read 回来的 revision 原来在 AI 侧毫无用处(409 只存在于画布保存那条路由),于是"AI 读 → 用户在 drawio 里改 → AI 写"会把用户的改动静默覆盖。现在基线对不上就报错且不写盘 |
连带修掉的两个坑,都在自测里留了断言:
① 别名/id 复用:id 是会被复用的(删掉 n3 再建一个,下一个还是 n3),复用的新节点
不该继承"用户之前选中过 n3" —— 所以删除时主动从选区里划掉(forgetSelection),
光靠读时的存在性过滤不够。
② 自测桩的 processPath(句柄):原来退化成 "[object Object]",两个不同文件算出同一个
absolute —— 任何"按文件配对"的逻辑(选区就是)在自测里会假绿。桩已按真实 fs 建模。
AI 侧 P1(这一版刚补齐,留档):把"客户端能做、AI 只能重画"的几件事补齐了:
| 做了什么 | 为什么 |
|---|---|
setEdge 重接边 |
以前换接点只能 remove+addEdge:id、标签、标签位置、折点、端点约束全丢 |
setLabelPos + read 的 labelX/labelY |
客户端能拖边上的字,AI 原来既看不见也改不了("文字压住线"只能让用户动手) |
order |
客户端能置顶/置底,AI 原来不能;写回会真的改文件里单元的先后(不然重开就变回去) |
duplicate |
客户端有 Ctrl+C/V,AI 只能重画一批 addNode+addEdge(折点/端点约束全丢) |
addLayer/setLayer/setLayerProps + layer 参数 |
AI 原来只能看层表;而且宿主写回根本不认模型的 layer(下面那条注记) |
export |
"给我一张 PNG"做不到:渲染器只在浏览器,于是做成了请求/回执通道 |
节点的 parent |
让"移动容器不带走子单元""删容器会重新挂父级"从暗坑变成已知 |
移层曾经是个假动作。
writeChainOf(写回时算"新父级"的那段)原来只看文件里的父链, 而模型里每个单元都带layer(读的时候沿父链得到的)—— 于是setLayer改了模型的 layer, 文件里的parent一个字都没动,重新读回来还是老层,看起来像"移动没生效"。 修法是"只有层真的变了才换父级":容器里的单元layer没变时必须保住容器父级, 不然每次保存都会把分组拍平(那比移层失效严重得多)。两条都留了断言。技能正文里的反引号是有毒的。
SKILL_BODY是模板串,markdown 里写`.drawio`会 提前闭合模板串,后面那段就成了 JS 代码 —— 表现是import lib/index.js抛TypeError: ".drawio" is not a function(而不是语法错误,因为"str" \x`是合法的标签模板)。 这一版踩了两次,tools/check-package.mjs` 的"正文讲了 …"关键字表就是为这类内容守门的。
AI 侧 P2(这一版刚补齐,留档):
| 做了什么 | 为什么 |
|---|---|
diagram_read 收 layer / ids |
大图上"先看一层""只看刚改的那几个"能省掉一大段上下文;过滤时同时报 totalNodes/totalEdges 与整份层表(不然模型不知道自己漏了什么) |
page(纸张尺寸) |
pageWidth/pageHeight 原来只在 mxGraphModel 上原样保留、AI 看不见;现在 read 报出来,从零重建时也照旧用它(不是悄悄变回 A4)。没写就不编 —— "这份文件没说"和"就是 A4"是两件事 |
| 回退栈(最多 8 步) | 原来是一层:一轮会话里 AI 改三次,用户按一下「撤销 AI 改动」就跳回最开始,中间两步无从恢复。现在是栈,每按一次退一步;read 的 revertSteps 是剩余步数 |
画布文件是 drawio 自己的 mxfile(.drawio)。没有"我们自己的格式 + 导入导出"这一层:
drawio 打开它、画布打开它、AI 改它,改的都是同一份文件。
难点不在读,在写。 drawio 的能力比本画布大(多页、图层、分组层级、图片、HTML 标签、 UserObject 自定义属性、旋转翻转、页面设置…),而本画布只理解其中一个子集。如果写回是 "按模型重新生成整份 XML",用户稿子里那些我们不理解的部分会在保存时被悄悄删掉 —— "悄悄"是最糟的失败方式(用户以为在编辑,其实在删)。所以写回是外科手术:
| 规则 | 说明 |
|---|---|
页 1 的 <root> 之外 |
其他页、mxfile 属性、空白 —— 逐字节保留 |
| 我们拥有的单元(节点/边) | 只重写 value/style/几何/端点这几处属性;<object> 包装上的自定义属性、<mxRectangle as="alternateBounds"> 原样留着 |
| 我们不认识的单元 | 原样留着(例如两端都没落点的边),不因为不认识就删 |
| 只有"我们导入过、模型里又没有了"的单元 | 才删 —— 那才是用户真的删了它 |
| 压缩形态 | 原本压缩的页体,写回后仍然压缩 |
| 容器子单元 | 坐标在文件里是相对父级的:读出来累加、写回去减掉。容器被删时,子单元被接到还活着的祖先上并补回坐标差(不留悬空 parent) |
| 元数据 | meta.pinned(人摆过版面,AI 别重排)存成文件里一个隐藏的 <object drawaiMeta drawaiPinned> 单元;revision 不落盘,直接用文件内容的指纹 |
由此得到一条可断言的性质:打开后原样保存,文件逐字节不变(check-mxfile 里 30 多条断言盯着它)。
有损的地方一律如实报。 多页一次只编辑当前页(其它页原样保留)、位图/外链图片按矩形显示(内嵌 SVG 会真的画出来)、HTML 标签
按纯文本显示 —— 这些都会作为 notes 出现在工作栏和 diagram_read 的返回里,
因为用户会拿这份文件继续在 drawio 里编辑,不说明就等于骗人。
边标签单元(drawio 的
edgeLabel)这一条同时是安全问题:把它当成普通节点读进来, 画布上会多一个鬼影框,一旦被拖动,写回就会把绝对坐标写进一个"相对"几何里, 把 drawio 里的标签偏移弄歪。所以它不作为节点导入、不拥有、写回原样保留; 只作为doc.labels读出来只读显示(见下面「边上的文字」)。
同一句"线上的字",drawio 按用户怎么操作存成两种形态(对着 Graph.js 与 mxGraphView.js 核实):
| 形态 | 谁存文字 | 位置存在哪 | 我们 |
|---|---|---|---|
| 边自己的标签 | 边单元的 value |
边自己的 mxGeometry:x = 沿边比例(-1..1,0 = 中点)、y = 垂直偏移 px、offset(<mxPoint as="offset">)= 残余 px |
读写:文字进 value(边上有自定义数据时进 <object label>),位置进 mxGeometry 的 x/y 与 offset 点 —— 能拖,与 drawio 同一套存法 |
| 独立标签单元(把标签拖走之后 drawio 会转成它) | 子单元的 value:style 带 edgeLabel、vertex=1、parent = 那条边 |
同一个子单元的 mxGeometry(relative="1" + x/y/offset) |
只读显示,不拥有、写回原样保留(不能编辑/拖动) |
落点两边是同一套公式:mxGraphView.updateEdgeLabelOffset → 几何是 relative 时走 getPoint(),
x = 0 即"沿折线走一半" = 弧长中点(不是包围盒中心,也不是某一段的中点)。
这里以前取的是"第 2 段的中点",于是同一份文件在两边画在不同地方 —— 折线一拐,
那一段可能只是 24px 的引出段,文字就贴在节点边上。现在 edgeLabelPosition(pts, edge) 走
edgeLabelPointAt(pts, labelX, labelY, labelOffsetX, labelOffsetY),缺省(没拖过)就是 0,0,0,0
= 弧长中点,与 drawio 对齐。
拖标签也是同一套:drawio 靠 mxEdgeHandler.moveLabel,我们靠它的镜像 ——
relativePointOnPath()(= mxGraphView.getRelativePoint)把指针位置反解成
x = 沿边比例、y = 垂距,再按 drawio 的存法收尾成 x 四位小数、y 取整、零头进 offset
(于是标签正好落在指针下,而文件里那两个数是干净的数)。手势上:
按住线上的字拖(越过 3px 才算拖,普通点选不改文档);右键菜单多一项「标签居中」把位置删掉。
拖动有吸附(与画布其余部分同一套单位:连线几何走半格 5px):
| 吸附 | 什么时候起作用 |
|---|---|
| 指针吸到半格 | 总是 —— 线本身在格线上时,标签自动落在线上(垂距 0) |
| 贴线吸附(垂距 ≤ 3px 并回 0) | 线不在格线上时(贴节点边框的那一轴是精确值,例如 x=455) |
线在文字的位置断开,而不是盖一层白底。 这是刻意的做法:labelGapsOnPath() 把"文字框"
与折线求交(正交路径每段只有横/竖,就是两个一维区间求交),cutPathByGaps() 按弧长把那段挖掉,
于是渲染出来是两条子路径(M… M…)—— 字的位置根本没有线,网格也不会被白块盖住。
两端各留 min(8px, 总长 20%):文字比这条线还长时(长标签压在短边上)不会连箭头一起消失。
命中带不挖空(线在字下面也要能点中/拖动)。
底衬只在样式明确要求时画(drawio 的 labelBackgroundColor,含它写出来的 8 位带透明度
#ffffffe0 → 拆成 fill + fill-opacity);默认不画。
写回只在真的变了才动手,且几何标签上的其它属性一律原样保留 ——
这里修掉过一个数据丢失:以前改折点会整块重建
<mxGeometry>,于是 drawio 拖过的 位置(自闭合几何上的x/y、或<mxPoint as="offset">)会被静默抹掉。 现在check-mxfile里两条回归盯着它(自闭合几何加折点、有折点时挪折点)。 非relative的几何是另一套语义(相对两端连线中点),我们不认这种位置,但属性原样保留。
还差的(诚实标注):独立 edgeLabel 单元不能编辑/拖动/新建、
单行纯文本(drawio 还认 html=1 多行、labelBackgroundColor、对齐与旋转,我们只认 fontSize/fontColor)。
旧格式(.dshd.json)已经彻底退场。 仓库里不再有这种文件,也不再有迁移脚本 ——
需要从旧版本升上来的话,用 git 历史里的那一版:
git show 873a835:tools/migrate-dshd.mjs > migrate.mjs && node migrate.mjs(它不删旧文件、
不覆盖已有目标)。
宿主与客户端在内存里交换的是一份语义文档(不是文件本身),字段按 drawio 的语义组织:
形状、配色、线型、箭头、端点约束都是 drawio 的 style 键,没有自己的一套封闭枚举。键名逐个对着
drawio 源码核实过(mxConstants.js、Graph.js、mxConnector.js、docs/claude/libavoid-routing.md),
所以同一个文件在 drawio → 本插件 → drawio 之间往返不会丢东西。
三条规则,与 drawio 一致:
- 默认值一律省略。
Graph.prototype.defaultVertexStyle = {}—— 普通矩形就是空样式 ("rect = 没有 shape 键")。endArrow缺省 = 不画箭头(mxConnector拿NONE当缺省)。 - 开放键值集合。 认不出的键原样保留、原样写回,只是不认识就不渲染 —— 于是 drawio 导出的 文件在这里编辑一轮再拿回去,陌生键不会消失(v1 的"认不出的只能丢"就是这么修掉的)。
- 折点与端点是两套模型。
points只放人摆的折点;"从哪一侧进出"是exitX/exitY(源端)与entryX/entryY(目标端)的比例约束(0 / 0.5 / 1 = 左·中·右、上·中·下);悬空端的自由点才是sourcePoint/targetPoint,且仅在该端没有真实顶点时生效(mxGeometry的原话)—— 悬空边照常渲染(v1 会把它整条丢掉),两端都悬空也能画。 - 图层是文档结构的一部分。
layers数组 + 每个单元一个layer(层 id)。 层不是我们发明的分组:它就是这个文件里没有几何的那几个<mxCell>(见「图层」一节)。 普通单层文件也照样有layers(就是那个缺省的<mxCell id="1" parent="0"/>,名字为空), 单元一律带layer—— 少写一个"有时有、有时没有"的字段,读的人少一处分支。
| 用途 | 键(drawio 名) |
|---|---|
| 形状 | shape=、ellipse、rhombus、rounded=、arcSize=、text(独立文字,drawio 的裸键)、shape=image + image=data:image/svg+xml,…(内嵌 SVG 图形,imageAspect 管拉伸/等比) |
| 配色 | fillColor、strokeColor、fontColor、strokeWidth |
| 线型 | dashed=1、dashPattern(画布缺省 3 3) |
| 箭头 | endArrow、startArrow(classic / none / …) |
| 路由 | edgeStyle=orthogonalEdgeStyle(none = 直线)、rounded=(折角是否圆滑)、curved=1(把折线抹成曲线) |
| 端点 | exitX/exitY、entryX/entryY、jettySize(引出段长度,数字或 auto) |
| 避让 | libavoidRouting=1(drawio 里它就是一个 per-edge 键) |
| 文本 | html=1、whiteSpace=wrap、fontSize、fontColor |
哪些键真的驱动渲染:形状 / 配色 / 线型(含
dashPattern间距)/ 箭头 / 路由(edgeStyle=orthogonalEdgeStyle与none直线)/ 折角(rounded)/ 端点约束(exitX·exitY、entryX·entryY)/jettySize/fontSize·fontColor·whiteSpace都真正生效;而html=1(本画布始终按纯文本渲染标签)、endSize·startSize·endFill这类箭头几何、libavoidRouting(本画布的路由本来就带避让惩罚) 以及任何陌生键,只是原样保留、不影响画面 —— 这不影响往返:它们不会丢。
工具语言 ≠ 文档语言。 AI 侧的 ops 仍然收 shape:'diamond'、dash:'虚线'、arrow:'双向'、
style:'yellow'、exit:'e' 这些糖,宿主翻成上面的键再落盘;keys:{…} 用来写任意 drawio 键
(值给 null = 删键回缺省)。drawio 自己也是这个分工:面板上给名字,文档里只有十六进制。
两条与"独立文字"有关的细节:shape:'text' 写出去的是 drawio 的 Editor.defaultTextStyle
(无边框无底色),而且对文字元素 style:'red' 会落到 fontColor 而不是填充色(见「独立文字」一节)。
一处刻意的规范化:drawio 自己写裸键
text;html=1;…,我们的parseStyle把"单独一个键" 规范化成=1(内核注释里就写着这两者等价,ellipse;同理),所以由我们新写/改过的文字元素 落盘是text=1;…。只读不改的单元一个字节都不动(无损写回的规矩),drawio 打开两种写法 都是同一段文字。
只有一种格式。 .dshd.json 那套语义枚举(shape:'rect'、style:'blue'、dash:'dashed'、
把进出侧混在 points 里的桩点)随载体切换一起退场了 —— 连同它的读时升级代码。留着一份
"另一种格式的迁移"只会让人以为还有第二种真相。
drawio 的文件就是 mxfile:<mxfile><diagram><mxGraphModel><root> 里一堆
<mxCell id value style parent source target vertex edge> + <mxGeometry>。文档层已经
按同样的语义组织(见上一节),所以style 串是原样搬运的 —— 颜色、虚线、箭头、
exitX/exitY 到了 drawio 里还是那些键,不需要翻译;真正要翻译的只有几何与结构:
| 结构 | 读进来 | 写回去 |
|---|---|---|
| 顶点 | mxGeometry@x/y/width/height;容器子单元的坐标是相对父级的,沿 parent 链累加成绝对坐标 |
按"画布上的绝对坐标 − 父链偏移"反算回相对坐标 |
| 单元 id | id 一般在 mxCell 上,但 <object label id> 包装的单元 id/label 在外层 |
标签写回外层 label,包装上的自定义属性原样留着 |
| 标签 | value(或 object@label);<br> 折成空格、其余标签去掉,实体解码 |
纯文本 + XML 转义(改过标签的单元会丢掉原来的 HTML 标签,已在 notes 里说明) |
| 折点 | <Array as="points"><mxPoint/></Array> |
语义没变就一个字节都不动 |
| 悬空端 | as="sourcePoint" / as="targetPoint" —— 只在该端没有真实顶点时才生效(有顶点时忽略,与 drawio 一致) |
同左 |
| 边端点 | source / target |
同左 |
| 新单元 | —— | 插到最后一个单元之后,parent 用当前图层(没设过就用第一个图层);缩进跟着这个文件走(硬编码会顺带改动别处的字节) |
| 图层 | root 下没有几何的 <mxCell>;value = 名字、visible="0" = 隐藏、locked="1" = 锁定;单元的 parent 指向层 id,层的先后 = 粗粒度 z-order |
只做属性级改动(value/visible/locked);隐藏写 visible="0",改回可见就删掉这个属性(不写 visible="1" 这种噪音);新层按同一套写法插入 |
压缩:drawio 默认把 <diagram> 的内容压成 base64(raw deflate(URI 编码的 XML))
(Graph.compress)。解压靠宿主(Node 有 node:zlib,浏览器没有),而且判形态是看内容
而不是看 compressed 属性 —— 属性标错的文件照样读得开。原有文件是压缩的,写回后仍压缩。
编码:UTF-8 首部的 BOM 会被摘掉(有的编辑器会加);若文件其实是 UTF-16(记事本的"Unicode" 另存),按 UTF-8 读进来会在字节之间夹满空字符 —— 这时报一句明确的编码错(请用 UTF-8 另存), 而不是硬按 UTF-16 重解释:那会把中日韩标签变成乱码,而乱码是看不见的损失。
代码只有一份:src/mxfile.js(parseMxfile / buildMxfile),由构建器原样拷成
lib/mxfile.js 供宿主半边 import;它不进客户端 bundle(浏览器没有 zlib,放进去只会白占体积)。
drawio 的图层不是"我们发明的一个概念",它就是这个文件里的几个没有几何的 <mxCell>:
value 是名字、visible="0" 是隐藏、locked="1" 是锁定,每个单元靠 parent 说自己属于哪一层。
画布认它们、按它们显示、让新东西进对层,并提供锁定 / 重命名 / 删层 / 移层 UI:
| 概念 | 存在哪 | 为什么 |
|---|---|---|
| 层 / 名字 / 有无 | 文件(就是那几个 <mxCell>) |
drawio 打开这份文件时看到的层必须和我们一样 |
| 显示 / 隐藏 | 文件(visible 属性) |
这是文档状态:它会被保存、被撤销、drawio 也照它显示。做成纯客户端开关的话,用户隐藏一层再保存,别的编辑器里它又冒出来了 |
| 当前图层 | 客户端状态(不进文件) | drawio 也是这样:当前层是编辑时的焦点,不是文档内容。关掉再打开回到第一层 |
| 层的先后 | 就是那些单元在 root 里的顺序 | 顺序 = 粗粒度叠放顺序;这也正好是"所有边都在所有节点下面"那个限制的绕法(把边放进上面一层) |
三条不能破的规则:
- 隐藏 ≠ 删除。 隐藏层的单元在文件里一个字节都不许动,写回时也不能因为"看不见"就当它不存在 (面板、框选、命中、避让都可以忽略它,但所有权判定不行 —— 那会把用户的图删掉)。
- 只有一个"可见性裁判"。 所有"这单元画不画/点不点得到/参不参与避让"的判断都走
buildGeometry这一个入口(它是渲染、命中、路由障碍的公共上游),隐藏层的节点在那里就被剔掉, 边与独立标签在各自的渲染循环里显式过滤。散在各处判断迟早会漏一处。 - 新单元进当前层。 新建节点、从空白处拉出的独立线、粘贴出来的单元都盖当前层的章; 粘贴时如果原件带着层就沿用原件那一层(复制一层里的东西,贴出来还在那一层)。
还有一条属于"文档对象"的规则:图层表必须一路跟着文档走,谁都不许"逐个字段抄"。
emptyDoc()(新建画布的内存形状)与 action:'create'(新建画布落盘的那一份)都走它,
带一个缺省图层 —— 与盘上那个 <mxCell id="1" parent="0" /> 对应,落盘字节完全相同,
只是让"刚新建"与"存过再打开"看到的是同一张画布(否则新建的画布在图层面板里
永远说"没有图层信息",一存一开又有了)。这条规则是被咬出来的,见下面那条注记。
界面入口在「图层」菜单:每层可切换显示 / 锁定 / 设为当前 / 重命名 / 删除(删层时单元合并到邻层, 不许删最后一层)+「+ 新建图层」+ 有选区时「选中 → 某层」。
回归断言:check-mxfile 的 [10] 图层一节(读层、隐藏属性级写入、显示回来时删属性、
新建单元进对层、多图层文件原样写回逐字节不变)、check-render 的图层一节
(隐藏层整层不画/点不到/框不到、新单元与粘贴进当前层)、check-host 的图层一节
(read 回图层表与每单元 layer、改图之后层不能掉、还不存在的画布也报一个缺省图层 ——
第一条抓到过 normalizeDoc 漏传 layers 导致"AI 一改图所有单元被挂回缺省层")、
check-render 的「文档在客户端里转一圈不能把字段吃掉」一节(见下)、check-component 的「工具条」一节
(把 layerList/activeLayerId/item/layerMenuItems/toolbarMenus 这段真实代码整体求值并调用,
所以"自由变量不在作用域里"会直接炸 —— 见下)。
四个自测盲区,各付了一次线上降级的代价。 ①
check-component原来把layerMenuItems桩成() => []:真实函数体一次都没跑过, 于是item(菜单项构造器)被写在toolbarMenus肚子里、而layerMenuItems在外层这种 作用域错位一路全绿 —— 用户打开面板看到的是ReferenceError: item is not defined+ "画布未渲染"。现在item / layerMenuItems / toolbarMenus这段真实代码被整体抽出来求值并调用, 我把item搬回去复现过:断言确实红。 ②check-component的 React 桩里createElement只记录元素、从不调用子组件, 所以renderOnce只跑了最外层那个注册组件,CanvasView本体从没被执行过 —— 渲染期的错误(这个 bug 就属于渲染期,它在toolbarMenus()里)它看不到。 ③ "逐个字段抄文档"没人管。 图层在五处被同一个毛病吃掉:宿主的normalizeDoc、 宿主的action:'create'(新建时手搭的那份空文档)、客户端的docFromPayload(宿主 → 客户端)、cloneDoc(每次本地改动与撤销快照)、pasteInto(粘贴)—— 都是{ version, meta, nodes, edges, labels }这么一个个列出来的,于是layers一路被丢。 用户看到的是"这张画布没有图层信息",而更隐蔽的是显示/隐藏变成空操作:applyLocal传进 mutate 的next是cloneDoc出来的、next.layers是undefined, 于是if (layers === null) return直接返回 —— 点了眼睛什么都不发生,也不报错。 现在四处统一改成"摊开整份文档再覆盖要深拷的那几个数组"(Object.assign({}, source, …)),check-render的「文档在客户端里转一圈不能把字段吃掉」一节拿键集合比对来钉: 基准是内核归一化后的文档,谁抄漏一个字段就报出缺了哪个 —— 以后内核再加文档级字段, 这几处必须自动跟上,而不是再靠人记得去改四个地方。同一类地雷还有一颗已经拆掉:
layerList/activeLayerId("当前层是哪个")原本算在菜单那段, 却有更早的三个函数要用它(新建节点/连线/粘贴盖章)。功能上能用(那些都是事件期触发, 组件体早跑完了),但只要哪天有人把它挪进渲染期,就会变成Cannot access 'activeLayerId' before initialization—— 同样的整画布降级。 现在它就在currentLayerId状态下面就地算,并且有一条断言盯着"它必须出现在最早用到它之前"。顺带记住 ③ 的运维含义:宿主半边没有热重载,所以"客户端已经修了、宿主还是旧的"这种半新半旧 状态会真的出现 —— 旧宿主仍然会把
layers丢掉,症状与没修一样。改完宿主必须重启dsh web。④ "断言某个处理器挂了"不等于"浏览器会把事件送到它手上"。
check-render里对双击只断言过边上的文字/把手(直接props.onDoubleClick({})调一下), 节点这一路一条都没有;于是onNodePointerDown里按下就setPointerCapture(画布)之后 (95cc9d7 的选区拖动顺手加的),双击节点改内容整个失灵却全绿 —— 因为捕获把click/dblclick的目标改成了画布容器,节点那个<g>根本不在事件路径里, 而我们测的是"函数调得通不通",不是"事件到不到得了"。 现在补两类断言:check-render新增「双击节点 = 就地改标签」一节(真的调用节点<g>上的处理器、 并检查 id/事件都传对),check-component新增「按下时不许抢 pointer capture」一节 —— 既做结构性守卫(setPointerCapture全文件只允许一处、且必须在takePendingCapture里), 也把takePendingCapture抽出来喂假事件真跑(1px 手抖不抢 / 4px 才抢 / 别的 pointerId 不抢)。 两类都做过破坏性复现:把 capture 挪回 pointerdown、把阈值改成 0,断言都确实变红。
| 手势 | 最小单位 | 说明 |
|---|---|---|
| 节点移动 | 一格 = 10px | 位置按 GRID 绝对吸附:指针再细,节点也落在格线上 |
| 节点缩放 | 一格 = 10px | 吸附的是尺寸(不是增量):186 + 30 → 220,而不是 216。对边原地不动,下限也取整格(60 × 40) |
| 连线折点 / 线段 | 半格 = 5px | EDGE_GRID = GRID / 2。连线比节点需要更细的手感;两个端点用同一个位移,段不会被吸附弄歪 |
| 对齐 / 分布 | 一格 = 10px | computeAlignMoves(…, GRID) |
| 自动路由的线段 | 半格 = 5px | 路由器自己选出来的坐标(端点接入位置、走廊、拐角)一律吸附到 5px;贴着节点边框的那一轴保持精确,不为了对齐把线从边框上挪开 |
| 新建节点尺寸 | 130 × 60(整格) | 文档里缺 w/h 时按 150 × 60 兜底;AI 侧按标签估宽也会向上取整到 10(estimateWidth) |
默认尺寸为什么要整格:旧的默认高是 56 —— 不是 10 的倍数,于是节点中心落在
y + 28上, 只要两个节点的中心差一两像素,连线就会多出"差一像素、合不成一条"的台阶。 现在新建节点是 130 × 60、兜底 150 × 60、AI 估宽向上取整到 10,中心永远是 5 的倍数。自动路由为什么要吸附:线段的位置来自"节点中心"与候选走廊中点。节点尺寸非整格时 (56 高 → 中心
y+28、186 宽 → 中心x+93),线段就会落在既不在整格也不在半格的坐标上。 所以路由器自己选的坐标一律吸附到 5px;而用户手摆的折点是用户数据,一律不动 —— 拖动时按半格吸附,已有文档里的坐标保持原样。 这两条都有断言:check-route-preview.mjs的"移动单位"与"自动路由:每条线段都落在整格/半格线上"两节。
为什么缩放吸附"尺寸"而不是"增量":文档里的宽度可能是 186 这种非整格值(估宽 / 导入 / 手写), 只吸附增量会一直把那个零头带着走(186 → 196 → 206…),中心也就永远落在半像素上 —— 那正是"两条线段差一像素、合不成一条"的上游来源。 这三条规则都有断言盯着:
check-route-preview.mjs的"移动单位"一节直接断言snapTo/resizeBox/segmentMoveOf三个纯函数。
文档带 meta.pinned: true(浏览器里存过盘就会打上)时,diagram_apply 默认 layout: 'none',
只给新节点补位,不动已有坐标;想重排必须显式传 layout。
这里踩过一个坑,值得记下来:
diagram_apply原本用整体赋值写doc.meta = { engine, layout }, 于是第一次 AI 改图就把pinned擦掉了 —— 那一次没事(mode 已经是none), 但第二次起读不到pinned,mode 回落dagre-tb,人摆好的版面被整张重排。 现在有回归测试盯着(tools/check-host.mjs的 "meta.pinned" 一节)。 教训:用整体赋值覆盖 meta 时,没被显式处理的字段会静默消失。
现象:某个节点上看起来一条直线上挂了两个段把手。
根因不在把手,在路径:两个节点的中心差几像素(自动布局取整的零头)时,
orthoV 会在"起点中心 x"和"终点中心 x"之间主动走一个台阶 ——
doc 中心 153、fs 中心 151.5 时生成
(153,476) (153,528) (151.5,528) (151.5,580)
视觉上是一条竖线,几何上是两条互相错开 1.5px 的线段,于是各长一个段把手。
修法是在路由的输入端对齐中心(snapNearAxis,容差 4px = 不到半个网格):
台阶根本不会产生,后续所有几何推理仍按原来的精确比较走。
走过两条弯路,都记在
simplifyCollinear的注释里:
- 事后去清理折线(把 1.5px 的台阶并掉)会凭空造出一条水平线, 再被共线消除吃掉,路径就断成半截(e5 会退化成从 y=528 起的 2 点线);
- 改
simplifyCollinear时用out里最后两个点当邻居,会让首点永远凑不齐两个邻居 而被判成共线删掉 —— 表现是 Z 形路径整段少掉一截。现在这两条都有断言守着:
tools/check-route-preview.mjs的"共线化简:必须保首尾" 与"中心对齐"两节,外加一节直接读demo.drawio、断言真图上没有碎段、没有挨在一起的把手。
选中一条连线时,每一段都有个橙色空心把手,拖它就是整段平移。这里曾经写着
if (segLen < 26) continue("太短的段不放,否则手柄会挤成一堆")—— 代价是短段整段挪不动,
而差几像素的台阶、贴边的引出段、两个节点挨得近时那一小截,恰恰都是短段(实测报过:
"短线段没有可移动的段点")。现在:
- 每段都给把手,半径随段长收(4 → 3);
- 首/末段的中点若离端点把手太近(两个圈会叠住,段和端点都抓不准), 把手挪到这一段的另一端(那个折点)—— 仍在这一段上,但离端点够远;
- 整条边只有一段时(首即末)不挪 —— 挪到哪一端都会正好压在端点把手上,留在中点反而最清楚。
tools/check-render.mjs 的"短线段也要有可拖的段把手"一节盯着它:段数 == 把手数、
每个把手落在它那一段上、按下去报到正确的段号。
判定要把盒子内缩 ε:贴着边框走不算"进去"(很多正常路径就走在边框上)。
折点可能落在某个节点内部 —— 用户把线拖到节点上,或者节点移动后把原本在外的折点"吞"了进去。 照直连过去线就扎进节点里,实测过:
折点在源节点内部 → (160,30) (80,30) (80,330) ... 从东边出去又折回节点里 80px
pushOutOfBox / boxContaining 在路由前把这类折点推到盒子外面(推最近的一侧,
优先沿"朝向另一端"的方向),只改路由用的副本,不动用户的折点数据。
回归测试见 check-route-preview.mjs 的"线不能穿进节点内部"一节。
已知未修:两个节点重叠或一个被另一个完全包住时,线仍会穿过被包住那个的内部 —— 那种几何下"不穿进去"本身就不可能(端点就在对方肚子里)。
已知未修:拖线预览在"指针靠近源节点那一侧的目标端点"时,会回穿源节点:
stubPointFor(src,'e')在 (184,30),而目标端点在下方,于是路径沿 x=184 往下走, 而 x=184 落在源盒的 x 区间内 —— 线段擦过源盒内部。根因是引出侧只看指针方向、 没有对整条路径做长度评估。修法是联合搜索两端(试过一次,见上一节,收益不抵复杂度)。
routeThroughWaypoints 逐步穿过用户摆的折点,每一步都是"两条 L 连过去"。
这两条的曼哈顿总长完全相同(走的都是同样的 dx、dy),差别只在拐点落在哪个角:
| 走法 | 拐点 | 结果 |
|---|---|---|
| 先横后竖 | (b.x, a.y) —— 与 a 同高 |
长的那一段先走完,短的收尾 |
| 先竖后横 | (a.x, b.y) |
短的那一段先走,后面多半要折回来 |
判据是 |dx| <= |dy| 时先横(即"先走跨度更大的一轴"),但它只知道 dx/dy 谁大:
"我们从哪儿来""接下来去哪"它都看不见,于是会挑出一条原路折返的 L(出去再回来 = 两段重合的线)。
两条 L 一样长,所以 connectOrtho 最后按重合段最短挑,完全平手才用默认判据。
重合有两处来源,两处都要看:
| 判据 | 看谁 | 病 |
|---|---|---|
firstOverlap |
上一段的方向(从 points 末两点读) |
第一步就压着上一段走回去(横着进来又横着往回走) |
arrivalOverlap |
下一站(connectOrtho 的第 5 个参数 next) |
到站方向被下一段立刻顶回来 —— 下一段是"直的"时必然折返 |
next 由 routeThroughWaypoints / selfLoopPath 在循环里传(最后一个折点的"下一站"是落点
borderPointToward),不传就只有第一条判据。
两条判据都用长度而不是"有没有":两边都躲不开时(用户折点自己摆成了来回), 重合 24px 总好过 44px。真机第二条截图正是这个岔口 —— 44px 的重合压在用户折点上 (
removeRetraces不许动它,于是重合留在画面上),24px 那条压在目标侧桩点上, 顺手就被消干净了。改成按长度选之后,那一整段拖动(7 个采样位置)都是零折返。原先的实现是"上一段是横的就先横后竖",完全不管目标在哪边。于是出现截图里那种线: 起点 (246,433) 先往右跑到 x=466,再折回左边 x=153 —— 实测 1341px 的路径, 改判据后降到 1073px;两折点那条 741 → 409。
试过两种更激进的做法,都回退了,记在这里免得再试:
- "平手时改成先竖" —— 595 → 649(起点在南侧时先竖着出去,下一段又得往北折回);
- "四条边各算一遍、取整条路径最短的起点" —— 只在部分场景有效,同样会把 595 弄成 649, 因为它先挑起点再算末段,末段那条 L 的方向没跟着一起优化。要做就得两端联合搜索。
剩下的绕行(截图那条 1073px 仍比自动路由的 323px 长)是折点本身摆成了往返: 466 → 153 → 466。路由器不该擅自重排用户的折点;想回到简洁路径,右键连线选「自动路由」即可 (
clearEdgeWaypoints会清掉全部折点)。
选 L 的前瞻能防住绝大多数折返,但防不住被数据逼出来的:
目标侧的桩点(stubPointFor,边框外 24px)与用户折点分居拐点两侧时,
无论先横还是先竖都要走一段回程。真机第一条截图就是这样来的 ——
把右侧节点从右往左拖,右下角那条线变成"往右走 44px 再原路描回来":
旧: (75,340) (75,445) (380,445) (336,445) (336,260) (360,260)
└── 这一段和上一段完全重合 ──┘ ← 用户看到"两条线"
新: (75,340) (75,445) (380,445) (380,260) (360,260)
pathOf 还会在每个顶点处画 6px 的圆角,于是尖点那里再鼓出一个小包 ——
"本来只有一条线段,现在像画了两条"就是这么来的。
去法很直接:a → b → c 而 b 是折返尖点(三点共线、方向在 b 处调头)时,
丢掉 b,等价于 a → c。新走线与 drawio 的 mxEdgeStyle.SegmentConnector
(OrthConnector 在有折点时就是回落到它)逐点一致:先竖着上去、最后一段横着进西侧,
中间那段压在节点底下看不见 —— drawio 的段连接器本来就不做障碍避让。
两条边界:
- 尖点若是人摆的折点就不动。 那是用户数据,动了会让
points[]与路径顶点失去一一对应 (段把手pathIndexOf找不到折点,拖不动)。路由器自己加的桩点/拐点才是这里要收拾的对象。 - 共线化简那遍故意不吃保护名单。 折返消掉之后,用户折点可能正好落进一条长直线中间:
线照样经过它、画面完全一样,硬留成顶点反而在同一条线上多挂一个段把手。
下一次手势的
prunePoints会把这条已经没有几何意义的折点从文档里清掉。
回归断言:isFoldApex 的四种情形、removeRetraces(非折点尖点被消 / 折点尖点不动 / Z 形不误伤)、
上面那条真机走线的逐点期望值、"同一判据在旧走线上能认出 1 段折返"(防假绿),
以及第二条真机截图那组(出口/入口同在东侧 + 折点在外侧):逐点期望值 +
拖动下方节点 7 个位置全程零折返 + 折点仍是路径顶点。
"预览"和"松手后真正画出来的线"是两套代码算的,只要判据有一点不同就会分叉 —— 用户看到的就是松手瞬间整条线跳掉。这里踩过两个具体的坑:
- 判据不同:预览按"被拖那一端选中的端点"决定要不要钉桩点,落盘却按
borderPointToward重算一次,还把固定端也算成了"现在不在自然侧"。于是把to端拖到目标 左边时,预览从左边接进去、落盘按"算法本来会选下边"处理 —— 两条完全不同的线。 - 方向反了:折点表按"被拖端 → 固定端"拼,
routeThroughWaypoints就倒着走; 拖from端时预览与落盘一正一反(渲染时反转折线只影响显示,救不了路径本身)。
现在两边都调用同一个 waypointsForRetarget,折点表一律按边的真实方向(fromBox → toBox)排列,
预览不再反转折线。回归测试覆盖两个方向 × 四个端点 × 有无折点共 12 种组合,逐点比对。
未验证(诚实标注):客户端半边是本仓库手写的,尚未在真实 DSH 里加载过;
remote.workspaceFiles.read/changes 的调用签名是从官方文件查看器
dsh-client-ui-sidebar-files 的用法推定的,首次安装可能要调一次。
要点只有一个:预览和落盘必须走同一条路由函数,否则松手那一刻整条线会跳变。
拖拽中(每一帧 pointermove)
toUserSpace(指针) → 用户坐标
hitNodeAt(…, HOT_PAD) → 指针下/附近是不是某个节点(按几何判定,容差 18px)
routePreviewFor(…) → 用 routeEdge / routeThroughWaypoints / stubPointFor 算正交折线
→ 渲染:虚拟预览折线(虚线) + 从折线终点到指针的收尾段(带箭头) + 起点圆点
命中目标时:目标节点外扩 6px 的脉动提示环 + 光标变 copy
松手时(onNodePointerUp)
同一套 stub 规则 → 写进 edge.points → 下一帧真实路由 = 刚才的预览
三个刻意如此的选择:
- 空白处也是折线,不是斜线。 目标位置用一个零尺寸虚拟盒喂给
routeEdge, 于是它照常从 6 个候选里挑一条正交走法 —— 预览里看到的绕行,就是松手后的绕行。 - 端点由用户选,不由算法猜。 拖线靠近某个节点时,该节点四个端点(上/右/下/左)会画出来,
选中的那个放大高亮,预览线就接在它上面;想换一边,把指针往那个端点挪近一点即可。
选中的两个端点会写成 style 里的
exitX/exitY与entryX/entryY(drawio 的固定连接点), 不写进edge.points—— 折点只放人真摆的折点,所以"看到接哪边"和"存下来接哪边"是同一件事, 而 AI 重排图形也不会把端点约束当成过期折点清掉。这条有一组断言盯着 (check-route-preview.mjs的"端点选择"与"改接端点"两节:四个方向 × 两个方向逐点比对预览与落盘)。 - 吸附判定用几何,不用
elementFromPoint。 预览要提前知道落点,而 DOM 命中测试 只在松手那一刻才成立;顺带也就有了"靠近即接"的容差(draw.io 的语义)。 松手落点仍然由elementFromPoint最终裁决。 - 钉住引出侧时预览可以穿过障碍。 用户按住某一侧的端点拖出来 = 指定了必经点, 路由必须照它走。这时预览的职责是如实预告,不是比落盘更聪明 —— 预览一旦自己绕开,落盘却照旧穿过,松手就又跳了。
这套几何逻辑可以在命令行里自测,不需要浏览器:
npm test # 155 项 mxfile 编解码/写回(含顺序/数据/标签单元/标签位置/图层/独立文字)+ 177 项路由/自环/迟滞/折返 + 221 项宿主(聚焦/注入/树布局/坐标/move/独立线/highlight/撤回/图层/独立文字/线型与字号)+ 436 项渲染/标签/挖空/框选/菜单当前值/字号夹取/辅助线/批量样式/全选/图层/字段不丢/双击改标签/独立文字/线型与字号 + 199 项组件(菜单真实求值/点外面关掉/指针捕获策略/几类并排下拉/横排与手动字号/独立文字入口/降级边界)(合计 1188)
# 路由那一节的条数跟着工作区里的 demo.drawio 走(每条边一组不变量),改了那张图就会变tools/check-mxfile.mjs—— mxfile ↔ 文档:拿真实产物形状的夹具读(<object>包装、 root/layer 的 id 带前缀、容器子单元的相对坐标、多页),压缩形态在测试里现压(与Graph.compress同一套算法)再读回,文档 →.drawio→ 文档的往返逐字段比对, 坏输入必须报错而不是给半张图;tools/check-route-preview.mjs—— 折线正交性、吸附容差边界、预览与落盘逐点一致(含改接端点 两个方向、带折点的边)、拖动线段松手不跳变、避让、共线化简保首尾、中心对齐容差、 折返段(出去又原路描回来)必须被消掉且人摆的折点尖点不许动, 以及直接读demo.drawio的真图连线不变量(最短段 ≥5px、段把手不重叠、零折返 —— 就是「一条线上两个点」和「本来只有一条线却画了两次」那类毛病);tools/check-host.mjs—— 用内存文件系统跑完整的diagram_apply/diagram_read, 并直接打写回路由验证.drawio的读 / 写 / 新建 / 另存为(打开后原样保存逐字节不变、 改一处不动别处、指纹过时 409、另存为不覆盖、非 .drawio 拒收),以及meta.pinned是否被保住、非法 ops 是否在写盘前失败(全或无)、边样式落盘; 宿主半边没有热重载(改完要重启dsh web),这个自测把反馈压到一秒内;tools/check-render.mjs—— 用极简 React 桩驱动renderDiagram:预览折线/收尾线/提示环 确实被画进了 SVG、连线画法(线型/箭头/marker 是否真的在 defs 里)、computeAlignMoves的对齐/分布坐标与幂等性,以及独立文字(不画边框底色、但留一个透明 命中框,字色由 fontColor 驱动)与"文字→矩形要把隐形设置清掉";tools/check-component.mjs—— 用带 hooks 的 React 桩把整个面板组件跑起来(状态形状不对时不能整块降级); 另有工具条一节:把item/layerMenuItems/toolbarMenus这一段真实源码整体抽出来求值并真的调用 (不再把layerMenuItems桩掉),所以"菜单项定义在看不见它的作用域里"这类自由变量错位会当场炸 (真实事故见「图层」一节末尾),另有一条断言盯着layerList/activeLayerId必须定义在最早用到它之前; 还有一节点到外面:把onRootPointerDown的真实函数体抽出来、喂假事件真的调用一遍 (菜单外面 / 菜单自己 / 开菜单的按钮 / 没有closest的 document / 输入框外面 / 输入框自己 六种目标),并断言 root 上真的挂了捕获处理器、按钮与输入框真的带那两个类名 —— 这类"算不算外面"的分支只能靠跑,grep 源码看不出来; 再一节按下时不许抢 pointer capture(见「图层」一节末尾的盲区 ④):既做结构性守卫 (setPointerCapture全文件只允许一处),也把takePendingCapture抽出来喂假事件真跑; 一节独立文字入口:右键菜单有「T 文字」、它对文字不套配色、放下即进编辑态、调色板改字色; 一节线型与字号入口:直线/直角折线/圆角折线/曲线四个按钮、线型的连带几何动作、 字号三个地方共用同一个控件(「默认」= 删键); 以及一节键盘归属:喂假 DOM 节点断言"输入框 / 别的面板里按键时画布一个键都不碰", 以及 window 上那个 keydown 确实先问归属再动手。
构建期的一道小闸门:
tools/build.mjs现在会检查src/index.js里反引号的总数是不是偶数 (客户端 body 早就有一条"代码行里不许有反引号"的检查)。原因是SKILL_BODY是一整段模板字符串, 在里面给键名加行内代码反引号会把模板字符串提前截断、整份lib/index.js语法错误, 而构建器照样"成功"写出产物 —— 这个坑一共踩了三次(每次都靠npm test的 SyntaxError 才发现)。 现在构建这一步就会报出"最后一个反引号在第几行"。
本机没有 typescript / tsdown / esbuild / react,无法跑构建链。
而本项目不需要代码转换:客户端半边全部用 React.createElement(无 JSX),
宿主半边是普通 ESM(无 TS 类型)。
src/ → lib/ 只做三件事:拷宿主半边、拷样式内核、把内核内联进客户端 bundle 再套外壳加缩进 ——
仍是零依赖 Node,不需要 tsdown / typescript / esbuild(本机也一个都没装)。
样式内核为什么要内联。
src/style-kernel.js是宿主与客户端共用的格式判据(解析 style 串、 默认省略、v1 读时升级…)。宿主是 ESM,直接import它;客户端的 bundle 是一个单文件 factory, 只有一份冻结的require表,不能 import 兄弟文件。于是构建器把它去掉export、包成 IIFE 命名空间(const styleKernel = (function(){…})())再内联 —— 包一层是因为两边有同名符号 (都有SIDES),平铺进同一作用域会直接 SyntaxError。源码只有一份,check-package会断言 客户端里那份确实是内联进去的。
lib/client.js 的包装格式逐字对照官方产物
(dsh-client-ui-sidebar-right/lib/client.js)确认:
window.__ModuleLoader__.load({ id: 'dsh-drawai', factory: (require) => module.exports })
⚠️ 绝不改lib/下的文件 —— 它们是构建产物,会被覆盖。改src/。 构建器刻意不加时间戳:dsh-client-hmr按内容变化判定 rebuilt, 时间戳会让每次构建都被当成变更,页面就会无谓重载。
将来若要用 TSX / TS 类型:补上 typescript + tsdown 即可,src/ 结构不用动。
npm run watch # 常驻:监听 src/,变化即重建 lib/
npm run build # 一次性构建
npm run check # 安装前烟测(含 lib 与 src 是否同步)| 改什么 | 怎么生效 | 要多久 |
|---|---|---|
src/client.js |
watch 重建 lib/client.js → dsh-client-hmr 轮询到内容变化 → SSE → 浏览器自动重挂载插件 |
约 1 秒,不用刷页面 |
src/index.js |
npm run build 后重启 dsh web(宿主半边只在启动时加载) |
一次重启 |
src/style-kernel.js |
两半都受影响:客户端那份走上面的热重载;宿主那份要重启 dsh web |
两者都要 |
package.json / cordis.patch.yml |
这两个文件被 watch,多数情况实时重组 | 立即 |
dsh-client-hmr 的要求只是"有进程在写 lib/client.js"——不限定是 tsdown。
tools/watch.mjs 直接 import 构建函数而不 spawn 子进程:
本机沙箱下 Node 的 piped stdio 会被拒,spawn('node', …) 会 EPERM。
提交信息用中文。 沿用本仓库既有历史的写法:一行标题带前缀(feat: / fix: / chore: / docs:),
必要时分点写正文 —— 说清"改了什么、为什么这么改",而不是"update"。
在 $DSH_HOME/profiles/<profile>/package.json 的 dependencies 里加:
"dsh-drawai": "link:D:\\_Project\\drawAi"然后:
cd $DSH_HOME/profiles/<profile>
pnpm installpackage.json 已声明 dsh.bundle.patch,用官方 CLI 安装即可自动挂载:
# 从 npm 装(推荐:预构建,不需要 allowBuilds 授权)
dsh plugin --profile web add dsh-drawai
# 或者直接从 GitHub 装(仓库里没有 prepare 脚本,同样不需要构建)
dsh plugin --profile web add github:fourzkw/dsh-drawai把这一行加进 $DSH_HOME/profiles/<profile>/cordis.patch.yml:
- insert:
- id: drawai
name: 'dsh-drawai'
⚠️ patch 文件不能为空或只有注释,否则 loader 启动失败。要留空请写[]。⚠️ 该文件被 watch,patchReload: live时改对即重组 —— 但新增一个 bundle 通常仍需重启dsh web。
宿主半边必须重启 dsh web 才会加载。 客户端半边是独立 bundle,页面刷新即可。
不需要 DSH 在跑,也不需要安装。先让它能解析 @deepseek-ai/dsh-tools:
New-Item -ItemType Directory -Force -Path node_modules\@deepseek-ai | Out-Null
New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-tools `
-Target "$env:APPDATA\npm\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai\dsh-tools"然后:
node tools/check-package.mjs它会验证宿主半边能否 import、apply 能否注册工具、以及 DSH 那套严格的 schema DSL 是否接受本包声明。
这个 junction 只是本机自测用的机器相关路径,不要提交,也不要在安装后保留: 装进 profile 后应当让包走部署自己的模块解析(同
dsh-better-sidebar的做法)。
package.json # dsh.bundle.patch + dsh.client 声明 + scripts
cordis.patch.yml # bundle patch:insert 一行 drawai
src/style-kernel.js # ← 源:格式判据(style 键解析/默认省略/v1 读时升级),两半共用
src/mxfile.js # ← 源:mxfile(.drawio)编解码,**只给宿主**(要 node:zlib)
src/index.js # ← 源:宿主半边(工具 + 布局 + 读写路由)
src/client.js # ← 源:浏览器半边(画布 + tab 类型)
lib/style-kernel.js # 产物(勿改)
lib/mxfile.js # 产物(勿改)
lib/index.js # 产物(勿改)
lib/client.js # 产物(勿改)
tools/build.mjs # 零依赖构建器(含内核内联)
tools/watch.mjs # 构建监视器(HMR 的那一环)
tools/check-package.mjs # 安装前烟测(含 lib 与 src 是否同步)
tools/check-mxfile.mjs # mxfile 编解码 + 无损写回自测(真实夹具 + 现压的压缩形态 + 逐字节不变)
tools/snap-geometry.mjs # 一次性几何迁移(对齐整格/半格;默认 --dry)
tools/check-route-preview.mjs # 连线预览的路由自测(纯几何,无需浏览器)
tools/check-host.mjs # 宿主半边行为自测(内存文件系统,无需 DSH)
tools/check-render.mjs # 渲染 + 对齐/连线画法自测(React 桩驱动 renderDiagram)
tools/check-component.mjs # 客户端组件自测(tab 注册、工作栏/菜单文案与入口)
demo.drawio # 示例图(画布默认读它;仓库里唯一被跟踪的 .drawio,其余被 .gitignore 挡住)
docs/design.md # 本文档
docs/release.md # 发布与收录流程
插件装到别的机器上时,跟着走的只有 lib/ 与 cordis.patch.yml(package.json 的 files),
README、src/、tools/ 都不会过去。于是模型能依仗的是三条通道:
| 通道 | 走什么 | 装到任何机器上都有吗 |
|---|---|---|
| 工具的 name / description / 参数与输出 schema | 就在 lib/index.js 里,DSH 会把它们交给模型 |
✅ 有 |
工具返回值与报错(diagram_read 的输出、diagram_apply 的 notes、非法 ops 的报错) |
运行时产生 | ✅ 有 |
| 内嵌技能(skill) | 插件在 apply() 里 ctx.skills.register(...) 注册一份内存里的使用说明 |
✅ 有(见下) |
第三条是关键:README 里那些"什么时候别用 layout""自环怎么写""为什么不能手改文件"的经验,
原本只活在这份仓库里。现在它们被写成一份技能正文(src/index.js 的 SKILL_BODY,约 3.7k 字),
注册进 DSH 的 skill 注册表:
- 会话的技能目录里出现一条简介(名字
drawai-canvas,带whenToUse),模型按需取全文; - 正文随
lib/index.js一起装到任何机器,不依赖工作区里有没有这个仓库; skills是可选依赖(走ctx.get('skills'),不写进inject):没装 skill 注册表的部署里 插件照常工作,只是少了这份说明;- 注册必须带
source: 'runtime'—— 注册时只校验 name/description/invocation, 但取全文时注册表会再跑一次validateDefinition,那里要求source是字符串 (少了它:目录里看得见、一 load 就抛source must be a string)。tools/check-package.mjs盯着这一条。
想让某台机器或某个项目补充自己的规则(比如"我们团队的图统一用 blue + daguerre"),
按 DSH 的文件技能放就行,不需要改插件:项目里放 <项目>/.dsh/skills/<名字>/SKILL.md、
用户级放 <DSH_HOME>/skills/<名字>/SKILL.md(frontmatter 要有 name 与 description)。
同名的项目技能优先级高于插件注册的运行时技能。
三条通道,都是"跟着插件走"的:
| 通道 | 机制 |
|---|---|
| 工具默认值 | 客户端在画布面板里切换标签页时 POST {action:'focus'},宿主按会话记下路径;diagram_read / diagram_apply 不传 path 时就用它(没打开任何画布才退回 demo.drawio)。两个工具的 path 说明里都写明了这一点 |
| 返回值 | 不传 path 调一次,返回里的 path / absolute 就是当前那张 —— 想确认是哪张,这一步就够 |
| 主动注入 | 聚焦变化时,宿主用 ctx.agents.get(sessionId).inject(...) 往那个会话注入一条环境事实({role:'user', source:{kind:'plugin:drawai'}};Session V4 要求 producer-owned kind,字面量 'plugin' 会被拒):DrawAI 画布:用户现在打开的是 <路径>。diagram_read / diagram_apply 不传 path 时默认就操作这一张。 于是模型下一步直接看得到,不用先探 |
细节:注入只在路径真的变了时才发(客户端每次挂载都会重报同一张,去重;A→B→A 是三次真实切换);
画布关掉时也发一条(否则模型还以为是刚才那张);agents 是可选服务(走 ctx.get),
没装 agent 服务的部署只是少这条主动提示,工具那条回退链照旧。
tools/check-host.mjs 用第二个实例(带假 agents)盯这四条。
"用户选中了哪几个"走的是另一条更轻的通道:客户端在选区变化时 POST {action:'selection'}
(防抖 300ms —— 框选时指针每动一下都会改选区,不防抖就是按帧打宿主),宿主按会话存
{绝对路径, ids},diagram_read 读那张图时带回去(selection)。三条规矩:认文件
(路径对不上不回 —— 两个文件里都叫 n1 太常见)、认存在(当前文档里没有的 id 过滤掉)、
删除即划掉(id 会被复用,删掉 n3 再新建一个往往又叫 n3,不划掉就会"继承"旧选区)。
选区不进文件:它是"用户在看什么"的提示,不是文档状态。
{ "version": 2, "revision": "9f2c1a4b7e03", // = 文件内容指纹,不落盘 "meta": { "pinned": true }, "layers": [ // root 下那几个没有几何的图层单元(顺序 = 叠放顺序) { "id": "1", "name": "主流程", "visible": true, "locked": false } ], "nodes": [ { "id": "doc", "label": "真相源", "style": "shape=document;fillColor=#fff2cc;strokeColor=#d6b656;", "x": 60, "y": 60, "w": 186, "h": 86, "layer": "1" } ], "edges": [ { "id": "e2", "from": "doc", "to": "canvas", "label": "变更流", "layer": "1", "style": "edgeStyle=orthogonalEdgeStyle;rounded=0;jettySize=auto;orthogonalLoop=1;html=1;endArrow=classic;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" } ] }