Devlog:把外部站点做成 DSH 原生侧边栏 Tab

Devlog:把外部站点做成 DSH 原生侧边栏 Tab

项目:dsh-my-page(注册到 dsh-better-sidebar 的第三方 Tab 插件)
目标站点:https://poe.want.biz(Cloudflare Worker + D1 的 OpenAI 兼容代理)
宿主:DSH Web GUI(http://127.0.0.1:3081,launchd 托管,label com.ygs.dsh-web-dev)
最终形态:零 iframe 的原生 React Tab,与宿主其它 Tab 同源同构,壁纸可透
代码规模:lib/client.js 1327 行(单文件 bundle),站点端 9 处改动,dsh-skin-suite 474 行


目录

  1. 需求与约束
  2. 第一版:iframe 嵌入
  3. 核心转折:为什么 iframe 走不通
  4. 测量方法论
  5. 原生重写:架构与契约
  6. 踩坑清单(13 个)
  7. 宿主插件生态的适配
  8. 最终成果与遗留
  9. 可复用的方法论总结

1. 需求与约束

1.1 原始需求

「可以把我的自建网页嵌入右侧工具上吗,放 agent browser 下方」

澄清后确定的边界:

维度 结论
站点 poe.want.biz
位置 右侧栏新标签页,排在「Agent 浏览器」下方
打开方式 只注册 Tab 类型,手动打开,不接管 urlTarget
后续追加 「没有透明度,看不到背景图片」——壁纸要能透出来

1.2 三条硬约束

  1. 不改 DSH 核心。/Users/ygs/ygs/deepseek-harness 是上游 checkout,改动会污染仓库、升级即丢。
  2. 走官方扩展点。dsh-better-sidebar 明确开放 ctx.betterSidebar.registerTab(),这是唯一正确的接入方式。
  3. 视觉必须原生。用户明确要求「跟 DSH 其他界面一样完美」,且壁纸要能透出——这两条合起来,直接把 iframe 方案判死。

1.3 宿主的关键事实(先摸清再动手)

动手前先读了三处源码,避免走弯路:

① Tab 注册契约(dsh-better-sidebar/lib/types/client/service.d.ts):

export interface TabDescriptor {  
  id: string                          // 也是 SidebarTab.type  
  title: string | (() => string)  
  description?: string | (() => string)  
  order?: number                      // + 菜单排序  
  single?: boolean                    // 单实例  
  component: (props: TabComponentProps) => ReactNode   // ← 关键  
}  

component 返回 ReactNode——宿主本来就期待原生 React 组件,不是 iframe。

② 共享模块表(packages/client/web/src/platform.ts):

export const PLATFORM_MODULES = [  
  'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis',  
  '@deepseek-ai/dsh-client-store',  
  '@deepseek-ai/dsh-client-ui-slots',  
  '@deepseek-ai/dsh-client-ui-primitives',   // ← Button/Menu/MarkdownText 都在这  
  '@deepseek-ai/dsh-client-ui-dockkit',  
] as const  

这份表决定了第三方插件「能拿到什么」:只有表内的模块能 require,其它一律不可。这是整个原生化的可行性基础。

③ 安装命令格式:dsh plugin --profile web add <包名>(文档明示),而我最初是手动 link + 改 profile 配置。


2. 第一版:iframe 嵌入

2.1 插件骨架

新建 /Users/ygs/ygs/dsh-my-page,一个手写 ModuleLoader bundle:

window.__ModuleLoader__.load({  
  id: 'dsh-my-page',            // 必须等于 package.json 的 name  
  factory: (require) => {  
    var React = require('react')  
    var h = React.createElement  
    // ...  
    return { name: 'my-page', inject: ['betterSidebar'], apply }  
  }  
})  

package.json 关键字段:

{  
  "name": "dsh-my-page",  
  "main": "./lib/index.js",  
  "exports": {  
    ".": "./lib/index.js",  
    "./client": "./lib/client.js",  
    "./package.json": "./package.json"  
  },  
  "dsh": {  
    "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-locale"] },  
    "bundle": { "patch": "./cordis.patch.yml" }  
  }  
}  

外加一个 cordis.patch.yml,用 - insert: 把自己加进模块表;lib/index.js 是 host 侧的空实现。Host 半区在 Web GUI 里根本不加载,但 Loader 要求它存在。

注册代码(这部分从头到尾没变过,是正确的):

var inject = ['betterSidebar']  
function apply(ctx) {  
  ctx.inject(['betterSidebar'], function (scope) {  
    var sidebar = scope.get('betterSidebar')  
    scope.effect(function () {  
      return sidebar.registerTab({  
        id: 'my-page:poe',  
        title: function () { return t('tab.title') },  
        order: 75,               // Agent 浏览器是 70,所以排在它下面  
        single: true,  
        component: function (tabProps) { return h(MyPageTab, tabProps) }  
      })  
    }, 'my-page: tab')  
  })  
}  

2.2 iframe 的第一版样式(这里埋了最大的坑)

.dsh-poe-frame {  
  position: absolute; inset: 0; width: 100%; height: 100%;  
  border: 0; border-radius: 10px; overflow: hidden;  
  background: var(--dsw-alias-bg-base, #fff);   /* ← 致命 */  
  box-shadow: var(--dsw-shadow-lv2, ...);  
}  

当时的想法很朴素:给 iframe 一个卡片底色,跟其它面板看起来一致。

2.3 站点侧改造

/Users/ygs/ygs/yuangs/poe/public/index.html(单文件 SPA,10033 行):

:root { --page-opacity: 1; }  
  
body {  
  background-color: rgb(18, 18, 24);                        /* 兜底,老浏览器用 */  
  background-color: color-mix(in srgb,  
    var(--background-dark) calc(var(--page-opacity) * 100%), transparent);  
}  
body.dsh-transparent { background-color: transparent; }  
body.dsh-transparent .main-content { background-color: transparent; }  
.main-content {  
  background: var(--background-dark);                        /* 兜底 */  
  background: color-mix(in srgb,  
    var(--background-dark) calc(var(--page-opacity) * 100%), transparent);  
}  

配套 JS(?opacity=0.6 / ?opacity=60 / ?opacity=60% 三种写法都收):

  • parseOpacity() — 解析并 clamp 到 0–1
  • setPageOpacity(n) — 在 documentElement 和 body 上都设变量,n === 0 时才加 dsh-transparent
  • window.setPageOpacity(n) / window.getPageOpacity() — 对外 API
  • postMessage({source:'poe-embed', type:'opacity', value}) — 宿主实时调

兼容性保证:不带参数访问 / 时 --page-opacity 恒为 1,渲染结果与改动前逐像素一致(实测 pageOpacityVar: "(unset)"、背景不透明)。这条很重要——不能为了插件把线上站点改坏。

2.4 部署事故

npx wrangler deploy 之后,public/index.html.bak.20260930_184708_pre-transparency 变成了可公开下载的资源(HTTP 200)。因为备份放在 public/ 里,Worker 的静态资源服务会照单全收。

修复:

  1. 备份移到 public/ 之外的 .backups/
  2. .gitignore 加 *.bak.* 和 .backups/
  3. 重新部署,确认 404

教训:任何要部署的静态目录,先确认里面没有「不该发布的东西」再动手。备份文件的默认位置就是个坑。


3. 核心转折:为什么 iframe 走不通

这是整个项目最花时间、也最有价值的部分。

3.1 症状

用户反馈三轮,逐级递进:

  1. 「打开页面跟原来效果不太一样,没有透明度,看不到背景图片了」
  2. 「只有我的网页那个页面区域没背景」
  3. 「托到最透明背景还是纯白不透明的」
  4. 「背景是变了,但换了颜色,感觉还是不透明」
  5. 「完全没变化」
  6. 「还是不行,肯定是哪容器的透明度为不透明,没有透明,遮挡了」

3.2 第一个真 bug:我自己给 iframe 刷的底色

background: var(--dsw-alias-bg-base, #fff) 是我自己加的。而 iframe 元素的 background 是在其文档之下参与合成的——所以这层白板永远压在站点内容底下,站点侧再怎么调透明度都没用。

改成:

.dsh-poe-frame {  
  ...  
  background: transparent;   /* 让站点自己的底色直接与宿主背景混合 */  
}  

在真实 DSH 应用里实测(把 .dsh-poe-root 挂进真文档后读 getComputedStyle):

{"frameBg":"rgba(0, 0, 0, 0)","frameRuleApplied":"10px","rootBg":"rgba(0, 0, 0, 0)"}  

borderRadius: 10px 证明规则确实命中(不是被丢弃),background 确实是全透明。

3.3 第二个真 bug:postMessage 发早了

原代码只在 mount 时发一次 opacity:

React.useEffect(function () { applyOpacity(opacity) }, [])   // ← 只发这一次  

iframe 刚创建,站点文档还没执行到 addEventListener('message'),这条消息就丢了。表现就是「拖动滑块没反应」。

改成在 iframe 自己的 load 事件里重发:

frame.addEventListener('load', function () {  
  if (mountedRef.current) setState({ status: 'ready', nonce: state.nonce })  
  postOpacity(rememberedRef.current)   // 文档已就绪,这次才落得下  
})  

rememberedRef 是必要的:load 回调只在 mount 时绑定一次,闭包里的 opacity 是第一次渲染时的值,用户后来改的值必须通过 ref 才能读到。

3.4 排除 sandbox

怀疑过 sandbox 属性破坏了透明。做了对照实验:同一页面两个 iframe,一个带完整 sandbox,一个不带,底下垫纯绿:

plain_no_sandbox: 5,255,5  
with_sandbox:    5,255,5     ← 两者一致,都透出绿色  

sandbox 无关。排除。

3.5 排除祖先遮挡

用户坚持「肯定有容器不透明」。做了完整祖先链审计,每一项都查(不只 backgroundColor,还包括渐变、opacity、backdrop-filter、mix-blend-mode):

div.dsh-poe-frame      rgba(0,0,0,0)   无渐变   backdrop: none  
div.dsh-poe-root       rgba(0,0,0,0)   无渐变   backdrop: none  
div.pT9sva_rightbarCol rgba(0,0,0,0)   无渐变   backdrop: none  
div.pT9sva_frame       rgba(0,0,0,0)   无渐变   backdrop: none  
body                   rgba(5,8,26,0.28)  
html                   rgba(0,0,0,0)  

再扫全文档的「大面积不透明元素」:

{"opaqueCount": 0, "top": []}  

一个都没有。 祖先完全干净。

又查了 backdrop-filter(Backdrop Root 会强制内部 iframe 不透明,是个真实机制)——文档里有 5 个 blur(14px)(皮肤插件的卡片),但没有一个在 Tab 的祖先链上。

(附带一个自伤:我第一版判据写成 cs.webkitBackdropFilter !== 'none',而它在 Chrome 里是 undefined,导致所有元素都被误判成 Backdrop Root。改成看标准属性 backdropFilter 才得到正确结果。)

3.6 决定性实验:把 iframe 藏起来

之前所有测量都有个隐含问题——「看到的颜色」到底是 iframe 画的,还是它背后画的?两者混在一起,永远说不清。

于是做了个差分测量:

// 同一坐标,测两次  
measure('iframe VISIBLE')   → centre RGB = 143,143,146  
hideIframe()  
measure('iframe HIDDEN')    → centre RGB =  50, 64, 87  

50,64,87 就是壁纸。宿主这边一切正常,壁纸就在那儿等着。

而 iframe 可见时是 143,143,146。再看一组数据,规律就清楚了:

opacity iframe 内像素
0.00 255,255,255(纯白)
0.28 143,143,146

透明度越低越白。 如果 iframe 正常与宿主混合,opacity=0 应该是最透(露出 50,64,87),实际却是纯白 255。

结论只能是:iframe 内部有一层不透明白板,站点底色只是叠在它上面。站点越透明,露出的白板越多。

最后补了一枪:在 DSH 文档里放一个完全透明的本地 iframe(data: URL,background:transparent,内容就俩字):

{"localTransparent_iframe":"255,255,255","poeSite_opacity0":"220,220,230"}  

本地完全透明的 iframe 也是纯白。 同一个浏览器、同一套代码,在普通网页里却正常透出绿色。

结论:跨域 iframe 是独立的浏览上下文,不与父页面合成。CSS 无法跨越这条边界。

3.7 为什么其它 Tab 看起来是好的

去查了 dsh-better-sidebar 和 dsh-ego-browser:

$ grep -c "createElement('iframe')" dsh-ego-browser/lib/client.js  
0  
$ grep -c "createElement('iframe')" dsh-better-sidebar/lib/client.js  
0  

零个 iframe。 任务管理、侧边对话、文件变动、Agent 浏览器——全是原生 DOM,直接活在宿主那套半透明面板体系里,壁纸天然透过。

它们不需要任何特殊处理,因为它们就是宿主的一部分。

3.8 决策

三条路:

方案 能解决透明? 工作量 结论
同源反向代理 ❌ 仍是 iframe 中 只能改善主题一致性
保持 iframe 接受不透明 — 零 治标不治本
重写为原生 Tab ✅ 大 选定

4. 测量方法论

这轮排查里,方法比结论更值得记录。

4.1 差分测量:藏起来再测

单点测量无法区分「谁画的」。把嫌疑对象藏掉,再测同一坐标,差值就是它的贡献。这个手法在后面定位「回复消失」「滚动条」时又用了一次。

4.2 用标准差判断「背景是否可见」

用户说「看不到背景图」,但背景图在页面区域是亮的(实测平均亮度 171),界面其它地方是暗的(47)。单看平均亮度会误判。

于是引入亮度标准差作为「图像细节是否可见」的指标——纯色块的标准差接近 0,有图像细节的区域标准差大:

区域 平均亮度 标准差 判读
左侧栏 62 26 壁纸细节可见 ✅
中间区 62 34 壁纸细节可见 ✅
我的网页 140 13 又亮又平,无细节 ❌

这张表一出来,「容器遮挡」的假设就站不住了——同一份壁纸在别处是可见的。

4.3 像素采样:绕开手写 PNG 解码

早先手写 PNG 解码器解出来的像素全是 0,白白浪费一轮。改用浏览器现成的:

const img = new Image(); img.src = 'data:image/png;base64,' + shot  
await img.decode()  
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height  
const g = c.getContext('2d'); g.drawImage(img, 0, 0)  
const d = g.getImageData(x, y, 1, 1).data  

Page.captureScreenshot → data URL → Image.decode() → canvas → getImageData。浏览器自己解的,不会错。

4.4 dpr:踩过两次

第一次:--window-size=400,300 却返回 500×100 的视口,采样点全错位。差点得出「iframe 是红色调」的荒谬结论。

第二次:忘了乘 devicePixelRatio,把 CSS 像素坐标直接当图像像素坐标用。写进探测脚本后统一处理:

const dpr = img.width / window.innerWidth  
const X = Math.round(x * dpr), Y = Math.round(y * dpr)  

凡是 CDP 采样,坐标一律先过 dpr。 这条写进了后续所有探测脚本。

4.5 OOPIF 会让执行上下文「凭空消失」

想在 iframe 内部读站点的 computed style,于是监听 Runtime.executionContextCreated 找 poe.want.biz 的上下文——

site contexts found: 0  
no site context — iframe did not create one  

误判成「iframe 没加载」。真相是:跨域 iframe 在 Chrome 里是 OOPIF(站点隔离进程),它的执行上下文属于另一个 target,不出现在页面 target 的 Runtime 域里。

改用 OOPIF 的 target 才拿得到。这个坑在「用 CDP 调试跨域 iframe」时必踩。


5. 原生重写:架构与契约

5.1 后端契约

先读 /Users/ygs/ygs/yuangs/poe/src/index.ts(455 行),把接口摸清:

接口 方法 用途
/v1/models GET 模型列表(OpenAI 格式 {data:[{id}]})
/v1/chat/completions POST 聊天,SSE 流式,代理到 aiproxy.want.biz
/api/history GET 会话列表 {id,title,model,created_at,timestamp}
/api/history POST Upsert 会话;只有 payload 带 messages 才重写消息
/api/history/:id GET 会话详情 {messages, conversation},支持 ?offset=&limit=
/api/history/:id DELETE 删除会话

数据模型(D1):

conversations(id, title, model, created_at)  
messages(conversation_id, role, content, raw_content, timestamp,  
         model, is_large_file, ai_summary, file_original_size, line_count, r2_key)  

认证:Authorization: Bearer <key>,前端共用 key 是 sk-frontend(与站点 localStorage.API_KEY 同源,用户可覆盖)。

关键发现:消息自带 model 字段。所以一个改了模型的会话,历史回复仍能正确标注是哪个模型生成的——UI 上直接用 m.model 即可,不用猜。

5.2 为什么这套契约适合原生重写

/v1/* 是标准 OpenAI 兼容接口,/api/history 是简单 CRUD。没有私有协议、没有 WebSocket、没有二进制帧。一个 React 组件 + 三个 fetch 就够了。

5.3 组件结构

PoeTab  
├── Toolbar  
│   ├── [左] 历史按钮 → MenuSurface 面板(每行悬停浮出 改名/下载/删除)  
│   ├── [中] 「模型:」+ 模型 Menu(下拉箭头)  
│   └── [右] 加号 → 新对话  
├── MessageList(滚到底部,滚上去就不强拉)  
│   ├── MessageRow(微信式气泡 + 悬停复制)  
│   │   └── MarkdownText(官方渲染器,支持流式)  
│   └── 流式指示(StateDot ongoing)  
└── Composer(自动增高 + 清空键 + 发送/停止)  

5.4 样式:全部走 token,不画自己的底

.dsh-poe-root { background: transparent; }  
.dsh-poe-msg-body {  
  border: 1px solid var(--dsw-alias-border-l1);  
  background: var(--dsw-alias-bg-layer-1);  
}  
.dsh-poe-msg.is-user .dsh-poe-msg-body {  
  background: color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);  
  border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 32%, transparent);  
}  

没有一个硬编码颜色(除 token 的 fallback 值)。这套做法带来三个结果:

  1. 跟随 DSH 明暗主题
  2. 跟随 dsh-skin-suite 换肤(它覆盖的是 --dsw-* token)
  3. 不遮挡壁纸——因为面板本身是半透明的,跟其它 Tab 完全一致

这就是「跟 DSH 其他界面一样完美」的实现方式:不是模仿,是复用同一套 token。

5.5 Markdown:用官方渲染器

第一版是纯文本 white-space: pre-wrap,用户反馈「回复的内容没有渲染 markdown」。

去查官方怎么做的,发现 ui-sidebar-documentpreview 的 MarkdownBody.tsx 只有 39 行——真正的渲染器是:

import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'  

而 ui-primitives 正在 PLATFORM_MODULES 里,可以直接 require。绕过了「插件不得互相依赖」的规则(那是运行时依赖,而这是共享静态库)。

API:

MarkdownText({ text, streaming?, labels, fileMentions?, pathImages?, variant? })  
// labels: { code: { copyLabel, copiedLabel }, footnotes: string }  

它原生支持 streaming——流式增量解析,正好对上 SSE 场景。

接线时踩了一个隐形的坑:labels 对象的身份决定渲染器能否复用流式缓冲。而我传给组件的 t 函数每次渲染都是新闭包,useMemo(..., [t]) 等于每次都重建。改成按语言 id 记忆:

var locale = props.__locale  
var mdLabels = useMemo(function () { return makeMarkdownLabels(t) }, [locale])  

6. 踩坑清单(13 个)

坑 1:iframe 自身的 background 会压在文档之下

现象:站点侧做到完全透明,宿主里仍是白板。
根因:background 在 iframe 文档下方合成,我刷的 #fff 成了永久遮挡层。
修:background: transparent。
教训:给 iframe 设背景色 = 给它垫一块板。

坑 2:postMessage 发在 listener 注册之前

现象:滑块拖动无反应。
根因:只在 mount 发一次,那条消息比 iframe 的脚本执行还早。
修:在 iframe 的 load 事件里重发;用 ref 而非闭包读最新值。

坑 3:跨域 iframe 是不透明的独立浏览上下文

现象:怎么调都不透明。
根因:浏览器架构,不是 CSS bug。本地透明 iframe 在 DSH 里也变白。
结论:原生化,别修。

坑 4:备份文件被部署成公开资源

现象:index.html.bak.* 可被下载。
根因:备份放在 public/,Worker 静态服务照发。
修:移到 .backups/ + .gitignore + 重新部署。

坑 5:Menu 的 anchor 是 ReactNode,不是 ref

现象:React error #31: Objects are not valid as a React child (found: object with keys {current})。
根因:我传了 anchor: modelAnchorRef(一个 ref 对象),Menu 直接把它当 children 渲染了。
修:anchor: h('button', {...}, label),并把按钮从原来的位置挪进 anchor 里(Menu 会把它包在自己的定位容器内)。
教训:ref 和 ReactNode 长得很像,TypeScript 在动态 bundle 里帮不了你。

坑 6:变量设在 <html> 上,子元素读到的是继承的陈旧值

现象:站点 opacity 设了 0.28,body 计算出来仍不透明。
根因:color-mix() 里的 var() 各自解析;body 继承到的是改动前的值。
修:setPageOpacity 同时在 documentElement 和 body 上设变量。
验证(CDP 三组对照):

方式 结果
变量设在同一元素(isolated) / 0.3 ✅
字面量(literal) / 0.3 ✅
从 root 继承(inheritedFromRoot) 不透明 ❌

坑 7:?opacity=0 与 embed=1 语义冲突

现象:显式传 opacity=0 却被 dsh-transparent 类覆盖。
修:applyFromQuery 只在没给 opacity 参数时才加那个类。

坑 8:scrollHeight 不含边框 → 单行输入框出现滚动条

现象:单行输入框也有滚动条;输入框高度是发送键的两倍。
根因:

el.style.height = 'auto'  
el.style.height = el.scrollHeight + 'px'   // scrollHeight 不含 border  

scrollHeight = 内容 + 内边距(不含边框)。把它当 border-box 高度写回,实际短了 2px → 判定溢出 → 出滚动条。而且因为反复重算,形成正反馈,输入框被撑到 68px。
修:

el.style.height = 'auto'  
var cs = window.getComputedStyle(el)  
var border = (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0)  
var wanted = el.scrollHeight + border  
el.style.height = Math.min(wanted, max) + 'px'  
el.style.overflowY = wanted > max ? 'auto' : 'hidden'   // 只有真超上限才滚  

CSS 侧再补 box-sizing: border-box 和 overflow-y: hidden 作为基线。
教训:跨 JS/CSS 边界传递尺寸,必须先统一 box-sizing。

坑 9:乐观更新后立刻丢弃,导致「回复消失」

现象:AI 回复正常流式显示,几秒后整段消失。
根因:

.then(function () {  
  setConversationId(id)  
  setLive(null)        // ← 立刻丢掉乐观副本  
  return refreshConversations()  
})  

live 是流式期间的数据源。丢掉它后视图回退到 threadState.messages——而那次 refetch 还在路上(通常是空数组),于是屏幕空白。
修:

// 1) persist 不再清 live  
reloadThread()  
  
// 2) 单独一个「接管」effect:等服务器那份真的追上了再换  
useEffect(function () {  
  if (streaming || !live) return  
  if (threadState.messages.length >= live.length) setLive(null)  
}, [threadState.messages, live, streaming])  
  
// 3) baseRef 同样要等追平,否则下一轮会基于残缺历史继续  
useEffect(function () {  
  if (streaming) return  
  if (live && threadState.messages.length < live.length) return  
  baseRef.current = threadState.messages  
}, [threadState.messages, live, streaming])  

教训:乐观更新不能在「写入成功」时丢弃,要在「读取确认」时丢弃。这两件事之间隔着一次网络往返。

坑 10:数据驱动的 Menu 放不下行尾按钮

现象:每行要挂 3 个操作按钮,只能退化成二级子菜单,体验很差(用户原话:「二级菜单体验不太好」)。
根因:MenuItem 只有 label(渲染在 <button> 内部),在 button 里塞 button 是非法 HTML。
修:改用 MenuSurface(文档明确允许自定义 listbox)自己拼行:

<MenuSurface role="menu" style={{ left, top }}>  
  <div className="dsh-poe-conv-row">  
    <button className="dsh-poe-conv-title">…</button>  
    <span className="dsh-poe-conv-acts">  
      <button title="改名">…</button>  
      <button title="下载">…</button>  
      <button title="删除" className="is-danger">…</button>  
    </span>  
  </div>  
</MenuSurface>  

配套自己实现关闭逻辑(pointerdown 外部 + Escape)和按触发器 rect 定位。
教训:组件库的数据接口有边界,超出表达力时换更底层的那一层,而不是硬塞。

坑 11:MutationObserver 全子树观察 → 强制重排风暴

现象:把皮肤切换器对齐到「设置」按钮下方后,界面明显发涩。
根因:new MutationObserver(anchorToSettings) 监听 document.body 整个子树,每次 DOM 变动都读一次 getBoundingClientRect()。
修:用 requestAnimationFrame 合并,一帧最多量一次:

let anchorQueued = false  
const anchorWatch = new MutationObserver(() => {  
  if (anchorQueued) return  
  anchorQueued = true  
  requestAnimationFrame(() => { anchorQueued = false; anchorToSettings() })  
})  

坑 12:Bundle rev 不变 → 浏览器一直吃旧代码

现象:改了好几轮,用户反复说「完全没变化」。
根因:rev 由包版本推导。版本一直停在 0.1.0,服务器下发的 rev=b44525f65bae 始终不变,浏览器缓存命中旧 combo bundle。
修:每次改代码就 bump version + kickstart,rev 随之变化(如 b44525f65bae → b434606315c6 → 180e5240d493)。

教训:无构建的 bundle 也要有失效策略。没有版本号,HMR 和缓存都会把你骗过去。

坑 13:webkitBackdropFilter 在 Chrome 里是 undefined

现象:判 Backdrop Root 时所有元素都命中。
根因:cs.webkitBackdropFilter !== 'none' 对 undefined 恒为真。
修:只看标准属性 backdropFilter。


7. 宿主插件生态的适配

过程中还顺手修了自己另一个插件 dsh-skin-suite(主题切换浮球)。

7.1 问题:浮球压住发送按钮

浮球是 position: fixed; right: 18px; bottom: 18px,正好压在右侧栏面板的操作区(发送键)。

先按用户要求移到左侧「设置」按钮下方:

const anchorToSettings = () => {  
  const settings = [...document.querySelectorAll('button,[role=button]')].find((b) => {  
    const s = ((b.getAttribute('aria-label') || b.title || b.textContent) || '').trim()  
    return s === '设置' || s === 'Settings'  
  })  
  if (!settings) { root.style.left = ''; root.style.top = ''; return }  
  const b = settings.getBoundingClientRect()  
  root.style.left = Math.round(b.left + b.width / 2 - 19) + 'px'  
  root.style.top = Math.round(b.bottom + 8) + 'px'  
  root.style.bottom = 'auto'  
}  

写死像素在窗口缩放/侧栏折叠/语言切换后会错位,所以每次布局变化都重量一次。

7.2 我自己制造的 bug:面板弹出方向

按钮移到底部附近后,我把面板从 bottom: 46px(向上展开)改成了 top: 46px(向下展开)——结果整块菜单落到视口外,用户反馈「点击没反应,菜单显示不出来了」。

改回 bottom: 46px(向上),并把水平对齐从 right: 0 改成 left: 0 以免伸出左边界。

教训:改位置时要连带想清楚弹出方向。

7.3 最终方案:搬进设置页,隐藏浮球

用户提议「把设置放到设置菜单里比较好,隐藏起来」。查了 ui-theme 的做法:

export const inject = ['slots', 'locale', 'remote', 'configForms']  
  
ctx.slots.inject('settings.general.item', () => ctx.slots.register({  
  name: 'settings.general.item',  
  id: 'appearance',  
  order: 10,  
  locale: SETTINGS_NS,  
  inject: injected,  
}, AppearanceRow))  

于是:

  1. exports.inject = ['slots'](拿 ctx.slots)
  2. package.json 的 dsh.client.inject 同步声明 ['slots']
  3. 注册 settings.general.item 行,渲染皮肤按钮组
  4. 只有注册成功才隐藏浮球,否则老版本 DSH 上就没有入口了
let settingsRowMounted = false  
try {  
  if (ctx.slots && typeof ctx.slots.inject === 'function') {  
    ctx.slots.inject('settings.general.item', () => ctx.slots.register({  
      name: 'settings.general.item', id: 'skin-suite', order: 30,  
      inject: () => ({ getSkin: currentSkin, setSkin }),  
    }, SkinRow))  
    settingsRowMounted = true  
  }  
} catch (err) {  
  console.warn('[skin-suite] settings row unavailable, keeping switcher:', err)  
}  
if (settingsRowMounted) root.style.display = 'none'  

工厂函数也要从 factory: () => 改成 factory: (require) => 才能拿到 React。


8. 最终成果与遗留

8.1 成果

指标 第一版(iframe) 现在(原生)
iframe 数量 1 0
壁纸可见 ❌ ✅(标准差 20~34,与原生 Tab 同级)
主题跟随 ❌ 站点自带配色 ✅ 走 --dsw-* token
换肤跟随 ❌ ✅ 随 skin-suite
Markdown ❌ 纯文本 ✅ 官方 MarkdownText,含流式
代码块复制 靠选中 ✅ 悬停浮出图标
微信式气泡 ❌ ✅ 左右分布 + 指向角
会话管理 只能看站点 ✅ 菜单内改名/下载/删除
运行时错误 — 0

8.2 遗留

  1. 站点侧的透明能力现在没人用了。?opacity= 这套参数对原生 Tab 已无意义(页面自己就在宿主文档里,不需要透出父背景)。留着无害,但代码上成了死路径。可择机清理,或保留给将来「在 iframe 里用」。
  2. 未做的功能:文件上传、搜索、会话内多模型对比。站点有的能力(/api/knasync/submit)没接。
  3. 打包方式仍是手工 link,没走 dsh plugin --profile web add。功能等价,但 profile 重置后要重新 link。
  4. 模型列表每次进 tab 都重新拉,没做缓存。

9. 可复用的方法论总结

9.1 三条判断

① 先问「这是 bug 还是机制」。
我花了六轮试图让 iframe 透明。如果第一轮就查到「grep -c createElement('iframe') = 0」,能省掉五轮。遇到「怎么改都不行」的现象,先确认它是不是压根不该work。

② 差分测量是唯一可靠的归因手段。
「看到的颜色是谁画的」这个问题,靠读 CSS 永远说不清。把嫌疑对象藏掉再测同一坐标,差值就是答案。这个手法我用了三次:iframe 归因、回复消失、滚动条。

③ 引入能证伪的指标,别用直觉。
「感觉背景没出来」是主观描述。换成「亮度标准差」后,宿主其它区域 26~34、我的 Tab 13,一眼看出问题在 Tab 自己身上——而这恰好证伪了「容器遮挡」的假设。

9.2 两处工程教训

无构建的 bundle 必须有失效策略。 没有版本号,HMR 和 HTTP 缓存会联手让你以为改了但其实没改。改代码 → bump 版本 → 重启,这条链路要固化。

跨 JS/CSS 边界的每个尺寸都要问一句「这是谁的盒子」。 scrollHeight 不含边框、min-height 受 box-sizing 影响——这类不一致会变成正反馈,把 1 行输入框撑成 2 行。

9.3 关于「完美」

用户要「跟 DSH 其他界面一样完美」。实现下来的结论是:「完美」不是把外部页面仿得像原生,而是直接用同一套 token、同一套组件,让它本来就是原生的。

前者永远差一层,后者没有缝。