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 托管,labelcom.ygs.dsh-web-dev)
最终形态:零 iframe 的原生 React Tab,与宿主其它 Tab 同源同构,壁纸可透
代码规模:lib/client.js1327 行(单文件 bundle),站点端 9 处改动,dsh-skin-suite474 行
目录
1. 需求与约束
1.1 原始需求
「可以把我的自建网页嵌入右侧工具上吗,放 agent browser 下方」
澄清后确定的边界:
| 维度 | 结论 |
|---|---|
| 站点 | poe.want.biz |
| 位置 | 右侧栏新标签页,排在「Agent 浏览器」下方 |
| 打开方式 | 只注册 Tab 类型,手动打开,不接管 urlTarget |
| 后续追加 | 「没有透明度,看不到背景图片」——壁纸要能透出来 |
1.2 三条硬约束
- 不改 DSH 核心。
/Users/ygs/ygs/deepseek-harness是上游 checkout,改动会污染仓库、升级即丢。 - 走官方扩展点。
dsh-better-sidebar明确开放ctx.betterSidebar.registerTab(),这是唯一正确的接入方式。 - 视觉必须原生。用户明确要求「跟 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–1setPageOpacity(n)— 在documentElement和body上都设变量,n === 0时才加dsh-transparentwindow.setPageOpacity(n)/window.getPageOpacity()— 对外 APIpostMessage({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 的静态资源服务会照单全收。
修复:
- 备份移到
public/之外的.backups/ .gitignore加*.bak.*和.backups/- 重新部署,确认 404
教训:任何要部署的静态目录,先确认里面没有「不该发布的东西」再动手。备份文件的默认位置就是个坑。
3. 核心转折:为什么 iframe 走不通
这是整个项目最花时间、也最有价值的部分。
3.1 症状
用户反馈三轮,逐级递进:
- 「打开页面跟原来效果不太一样,没有透明度,看不到背景图片了」
- 「只有我的网页那个页面区域没背景」
- 「托到最透明背景还是纯白不透明的」
- 「背景是变了,但换了颜色,感觉还是不透明」
- 「完全没变化」
- 「还是不行,肯定是哪容器的透明度为不透明,没有透明,遮挡了」
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 值)。这套做法带来三个结果:
- 跟随 DSH 明暗主题
- 跟随
dsh-skin-suite换肤(它覆盖的是--dsw-*token) - 不遮挡壁纸——因为面板本身是半透明的,跟其它 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))
于是:
exports.inject = ['slots'](拿ctx.slots)package.json的dsh.client.inject同步声明['slots']- 注册
settings.general.item行,渲染皮肤按钮组 - 只有注册成功才隐藏浮球,否则老版本 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 遗留
- 站点侧的透明能力现在没人用了。
?opacity=这套参数对原生 Tab 已无意义(页面自己就在宿主文档里,不需要透出父背景)。留着无害,但代码上成了死路径。可择机清理,或保留给将来「在 iframe 里用」。 - 未做的功能:文件上传、搜索、会话内多模型对比。站点有的能力(
/api/knasync/submit)没接。 - 打包方式仍是手工 link,没走
dsh plugin --profile web add。功能等价,但 profile 重置后要重新 link。 - 模型列表每次进 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、同一套组件,让它本来就是原生的。
前者永远差一层,后者没有缝。