OpenBot 共享 UI 层实战:@openbot/ui 的导入约定、样式装载与边界设计
OpenBot 共享 UI 层实战:@openbot/ui 的导入约定、样式装载与边界设计
@openbot/ui 是 OpenBot 桌面端、公开 Web 客户端与 Storybook 共用的 SolidJS 组件库,同一份实现同时服务三个消费端。本文以该包的 README 为主线,结合 package.json 的导出表、src/index.ts、src/complex.tsx 等源码,讲清如何正确导入组件、如何按顺序装载样式、哪些能力必须留在消费端,以及仓库用哪些自动检查守住这条边界。读完可以直接在 OpenBot 的渲染端接入既有组件,或为这个包新增一个可复用特性组件。
一、包的定位:一份实现,三类消费端
README 开宗明义:源码是从桌面渲染器(desktop renderer)迁移过来的,只有一个实现。这意味着不要再在 src/renderer/src/features/<domain>/ 里复制一份组件——桌面端、Web 客户端和 Storybook 都消费 packages/ui 里的同一个文件。
| 消费端 | 是否消费 @openbot/ui |
说明 |
|---|---|---|
桌面渲染器 src/renderer |
是 | 主要消费方,特性目录通过包路径导入共享组件 |
公开 Web 客户端(如 /app) |
是 | 通过 src/renderer/src/features/web-client/ 复用同一批组件与 store |
| Storybook | 是 | .storybook/main.ts 同时收集渲染器、apps/auth-api、apps/status 的 story |
原生移动端 apps/mobile |
否 | 只共享品牌令牌与契约,不消费 SolidJS DOM UI |
从源码规模看,packages/ui/src 下约有 338 个 .tsx、110 个 .ts 和 102 个 .css 文件,既有 button.tsx 这样的基础原语,也有 features/conversation/、features/sidebar/ 这样的完整特性域。它们同属一个包,但对外暴露的方式并不相同。
二、导入约定:主入口只放原语,特性走显式子路径
packages/ui/package.json 的 exports 是整个包的公开契约。它分成三类:
- 主入口
.→./src/index.ts,只转发基础原语(alert、badge、button、checkbox、select、toast、typography、user-avatar等),见 src/index.ts; - 显式特性子路径,例如
./features/sidebar/Sidebar、./features/conversation/ComposerEditor、./features/account/AccountLogin、./features/servers/AddServerDialog; - 样式子路径,例如
./styles.css、./features/conversation/conversation.css、./styles/action-menu.css。
README 给出的示例正是这三种用法的组合:
import { Button, Input } from "@openbot/ui";
import { cx } from "@openbot/ui/utils";
import { Sidebar } from "@openbot/ui/features/sidebar/Sidebar";
import { AccountLogin } from "@openbot/ui/features/account/AccountLogin";
import { ComposerEditor } from "@openbot/ui/features/conversation/ComposerEditor";
真实调用点与示例一致。桌面端侧边栏在 WorkspaceSidebar.tsx 中导入 Sidebar、SidebarCommunityCard、SidebarMobileAppCard、SidebarStorageCard;输入框在 ConversationComposer.tsx 中导入 ComposerEditor;Web 客户端的设置页在 WebAccountSettings.tsx 中同时导入原语、设置页组件、createSettingsProfileStore 与 useText。
为什么特性不塞进主入口?因为特性子路径是导入契约:调用方写的是 @openbot/ui/features/sidebar/Sidebar,而不是某个内部相对路径。只要这个键不变,包内文件怎么拆分都不影响消费端——Sidebar.tsx 里把结构拆成 SidebarFrame、SidebarNav、SidebarSearch、SidebarDialogs,消费端一行都不用改(见 Sidebar.tsx)。
三、样式装载:令牌、原语样式、特性样式三段式
README 的这条要求最容易出错,需要逐字理解:
Load
@openbot/brand/tokens.cssand@openbot/ui/styles.cssin the consumer stylesheet. Tailwind consumers must scanpackages/ui/src.
3.1 三条装载规则
- 先装令牌:
@openbot/brand/tokens.css是全局--openbot-*变量的唯一声明处,深色为基准,浅色在@openbot/brand/tokens-light.css里只改值、不新增变量(见 docs/ui-foundation.md)。 - 再装原语样式:
@openbot/ui/styles.css实际指向./src/styles/primitives.css,这是Button、Text、Dialog等原语的规则来源。 - Tailwind 消费端要扫描包源码:
packages/ui/src里有动态拼接的类名,Tailwind 的扫描范围必须覆盖它,否则这些类不会被生成。
渲染端样式表的顺序完全遵循这三条:
@import "tailwindcss";
@import "@openbot/brand/tokens.css";
@import "@openbot/brand/tokens-light.css";
@import "@openbot/brand/logo.css";
@import "@openbot/ui/styles.css";
@import "@openbot/ui/features/account/otp-input.css";
...
对应文件见 src/renderer/src/styles.css。其中 Tailwind 的扫描声明是单独一行:
@source "../../../packages/ui/src";
3.2 特性样式必须显式按序引入
特性 CSS 不在 styles.css 里自动展开,而是通过显式的样式子路径逐个 @import,并且顺序就是级联顺序。渲染端把 @openbot/ui/features/conversation/conversation.css 放在 provider-code-login.css 之后、各特性覆盖规则之前(styles.css),这就是设计意图。
conversation.css 本身又有自己的有序清单:
/* Import order preserves the conversation cascade. Keep later overrides after base rules. */
@import "katex/dist/katex.min.css";
@import "./styles/layout.css";
@import "./styles/header-layout.css";
@import "./styles/file-preview.css";
@import "./styles/messages.css";
@import "./styles/composer.css";
...
@import "./styles/surface-overrides.css";
见 features/conversation/conversation.css。对应的分片目录是 features/conversation/styles,约 30 多个文件。规则很明确:不要把分片单独引入,也不要打乱清单顺序——surface-overrides.css、browser-previews.css 这类文件的存在就是为了在基础规则之后覆盖。
这种做法有一个副作用值得注意:conversation.css 会连带引入 katex/dist/katex.min.css,说明数学公式排版是该特性样式的一部分,任何复用会话组件的消费端都会一并获得它。
3.3 公开站点的不同用法
并非所有消费端都需要全量样式。apps/auth-api 的样式表只引了 @openbot/ui/stagger.css 这类单点样式(apps/auth-api/src/styles.css),说明公开页面按需引入子路径,而不是整套 styles.css。这也是 exports 里存在 ./stagger.css、./skill-preview.css 这些细粒度键的原因。
四、用 cx 组合类名,用令牌代替魔法值
README 的示例里特意保留了 import { cx } from "@openbot/ui/utils";。这个工具在 utils.ts 中定义,接受字符串、数组、条件对象与 false/null/undefined,最终拼成一个类名字符串:
export function cx(...values: ClassValue[]): string {
return values
.flatMap((value): string[] => {
if (!value || value === true) return [];
if (Array.isArray(value)) return [cx(...value)];
if (Object.prototype.toString.call(value) === "[object Object]") {
return Object.entries(value)
.filter(([, enabled]) => enabled)
.map(([className]) => className);
}
return [String(value)];
})
.join(" ");
}
同一个文件还提供了共享 UI 常用的小工具,它们看起来琐碎,但都被多处复用:
| 工具 | 作用 |
|---|---|
truncateMiddle(value, maxLength) |
从中间截断长路径,保留头 2/3 与尾 1/3 |
prefersReducedMotion() |
读取 (prefers-reduced-motion: reduce),jsdom 下 matchMedia 可为空 |
usesTouchLayout() |
读取 (hover: none), (pointer: coarse) |
clamp / mix |
数值边界与线性插值,供动画进度使用 |
motionDuration(name, fallback) |
从根元素读 CSS 时间变量,兼容 ms 与 s |
createPressedPopupCue() |
为 pointerdown 触发的弹层提供开/关音效标记 |
颜色、字号、圆角、过渡时长必须走 --openbot-* 变量。primitives.css 里的按钮变体就是范例:默认按钮用 --openbot-surface-light + --openbot-text-on-light,描边按钮用 --openbot-border + --openbot-bg-control(primitives.css)。这条纪律由自动检查强制,见第七节。
五、组件分层:原语、特性组件与共享显示模型
README 对分层的描述可以落到三个目录:
- 原语:
packages/ui/src/*.tsx,如button.tsx、form.tsx、surface.tsx、typography.tsx; - 特性组件:
packages/ui/src/features/<domain>/,覆盖账户、侧边栏、服务器导航、消息渲染、编辑器、附件、预览、Agent 设置与设置面板; - 共享显示模型:
packages/ui/src/data.ts,从@openbot/contracts/ipc引入AgentAccess、AttachmentSummary、ConversationMessageSender、ClientAgentSummary等类型,供特性组件以类型化 props 接收数据(data.ts)。
README 还点出了包内还包含的具体能力:直接会话、问题提示、provider 代码登录、provider 选择、技能预览、面板缩放、文件预览与浏览器预览卡片。这些都在导出表里有对应条目,例如 ./features/conversation/DirectConversation、./components/QuestionPromptBubble、./components/ProviderCodeLoginDialog、./components/ProviderPicker、./components/SkillPreview、./components/PanelResizer、./features/conversation/FilePreviewPanel、./features/conversation/BrowserPreviewSidebar。
关键约束是:组件的本地交互状态可以留在包内,但账户会话、Host 传输、桌面持久化和完整会话控制器属于消费端。 侧边栏的 SidebarProps 就是这条规则的样本——它接收 agents、channels、layout、onMutateLayout、onDeleteAgent、pinnedItems 等数据和回调,自己不持有服务器状态(sidebar-types.ts)。
六、边界:包内不得触达应用上下文与平台
README 的原文要求是:
Keep components independent of application contexts, Electron, storage, authentication, and network clients. Receive data and callbacks through typed props. Do not import files from
src/renderer,src/main,src/backend, orsrc/preload.
packages/ui/AGENTS.md 把它进一步具体化:不要在包内调用 window.openbot,不要导入渲染器、主进程、后端或预加载代码;移动组件,不要复制组件。
6.1 唯一例外:界面文本
packages/ui/AGENTS.md 明确写了唯一的应用上下文例外——界面文本:
The one exception is interface text: call
useText()from@openbot/ui/textfort,formatanderrorMessage. Do not add atprop.
原因在 text.tsx 的注释里:几乎每个组件都要渲染文本,如果把翻译器塞进每个 props 列表,会为了没有收益的可测试性改动所有签名。TextValue 因此提供:
| 成员 | 用途 |
|---|---|
locale() |
当前界面语言 |
t(key, ...params) |
按语言查词条 |
format |
数字、紧凑数、百分比、美元、日期、列表、文件大小 |
errorMessage(error, fallback) |
按语言给出用户可读错误 |
errorDetails(error, fallback) |
带错误码的版本,配合 ErrorReference |
sourceText(text) |
把 Host 或主进程发来的英文源文本本地化 |
没有 Provider 时默认英文,所以单测、story、脱离应用的页面都不需要额外配置:
export function useText(): TextValue {
return useContext(TextContext);
}
消费端在渲染器里由 I18nProvider 提供该上下文(i18n-context.tsx),Web 客户端与 Storybook 则使用固定语言的静态 Provider。更多细节见 docs/i18n.md。
6.2 平台能力通过适配器传入
README 说得很清楚:渲染器适配器负责提供翻译、链接打开、预览截取与面板偏好,并且保持存储键与原生行为不变。代码里能看到这种形态:
FilePreviewPanel不自己打开链接,而是接收onOpenLink、onOpenSharedFile、onOpenWorkspaceFile、onOpenExternally、onDownload、onReveal(FilePreviewPanel.tsx);BrowserPreviewSidebar不自己截屏,而是接收capturePreview?: (tabId) => Promise<BrowserPreview>,面板宽度的读取与保存也通过readWidth、readCustomWidth、saveWidth、resetWidth注入(BrowserPreviewSidebar.tsx)。
好处是可测性与平台无关性:同一个组件在 Electron、Web 与 jsdom 里都不需要伪造全局对象。
七、自动检查:把边界写成可执行的约束
README 的约束不是靠 code review 维持的,而是由 bun run check:ui 与 Biome 插件共同拦截。命令定义在 package.json,脚本是 scripts/ui-foundation-check.ts。
7.1 check:ui 检查什么
从实现看,它对渲染端与 packages/ui 之外的源码做以下判定:
| 检查项 | 触发条件 |
|---|---|
| 原生控件 | 出现 <button、<input、<textarea、<select 标签 |
| 直接依赖 Kobalte / Lucide | from "@kobalte/core" 或 from "lucide-solid" |
| 手写开关 | role="switch" |
| 内联颜色字面量 | color、background、border-color、fill、stroke 等写成 #hex、rgb()、hsl() 或命名色 |
| 内联未令牌化尺寸 | font-size、border-radius、transition-duration 未用 var() |
| Kobalte 直别名 | complex.tsx 里出现 export const X = YPrimitive; |
| 不可达 CSS | 样式表声明的类名在组件、story、HTML 入口中都不出现 |
data-testid |
渲染端标记里出现即计债 |
最后还有一组"迁移预算",全部为 0,且只允许下降:
const debtBudgets = [
["hand-rolled composite ARIA roles", manualCompositeCount, 0],
["data-testid hooks in renderer markup", matches(testHookSource, /data-testid\s*=/gu), 0],
["colour literals outside the palette", colorLiteralCount, 0],
["untokenized font-size", ..., 0],
["untokenized border-radius", ..., 0],
["untokenized transition durations", ..., 0],
] as const;
脚本还特意把扫描根放宽到 src、apps、packages/ui/src 整个目录(ui-foundation-check.ts 末尾的 checkUiFoundation(...) 调用),理由是类名可能来自渲染器之外:主进程会注入画中画窗口的类名,apps/auth-api 也会渲染预览外壳。同文件注释记录了两次"检查静默匹配不到任何东西却一直绿灯"的历史,这正是 tools/ui-foundation/fixtures 存在的原因。
7.2 Biome 插件与 fixture
tools/ui-foundation/ 下有三个 GritQL 规则:
no-desktop-preload.grit:拦截window.openbot、window?.openbot、globalThis["openbot"]等八种访问形态,报错信息是"Shared UI must receive platform actions through props, not the desktop preload.";no-hardcoded-ui-text.grit:拦截 JSX 文本、aria-label/title/placeholder/alt等属性里的字面量、toast(...)的首参、以及errorMessage(err, "英文兜底")的字面量兜底;no-story-play.grit:禁止在新 story 里写play函数。
这些规则的正反例都放在 tools/ui-foundation/fixtures/renderer 与 renderer-clean,由 scripts/ui-foundation-check.test.ts 逐条断言——包括 manualCompositeCount 期望为 9、干净目录期望为空数组。fixture 目录本身被 biome.json 排除,也不进任何 tsconfig,只当数据读。
八、复杂交互:Kobalte 适配层与焦点恢复
README 要求"使用仓库锁定的 Solid 2 运行时与打过补丁的 Kobalte"。这条在根 package.json 的 catalog 里是精确版本:@kobalte/core 为 2.0.0-alpha.0,solid-js 为 2.0.0-rc.18,并且通过 patchedDependencies 应用 patches/@kobalte%2Fcore@2.0.0-alpha.0.patch(该补丁把 mergedProps.ref 包进 untrack,以适配 Solid 2 的引用语义)。
src/complex.tsx 因此是唯一允许 import Kobalte 的地方,并且把上游结构收敛成稳定的公开名字:Dialog、AlertDialog、DropdownMenu、ContextMenu、Popover、Tooltip、Tabs、RadioGroup、SelectPrimitive、Combobox、Listbox。
8.1 alpha 版本丢失焦点后的补偿
适配层最实质的工作是焦点恢复,focusRestoreHandler 的注释解释了策略(complex.tsx):
- 打开时记录
document.activeElement作为恢复目标; - 关闭时查找
[aria-haspopup][aria-expanded="true"][aria-controls],若其控制的元素是dialog/alertdialog/menu,就把触发元素作为恢复目标; - 只有"焦点确实由该表面拥有"(焦点在 body、在恢复目标上、或在对话框内)才恢复,用户主动点进输入框的焦点不会被抢走;
- 恢复动作放在双层
requestAnimationFrame里,并在执行前再确认焦点没有被别的操作移走。
菜单更严格,用 createMenuFocusRestore 记录 epoch、handedOff 与 content 是否包含当前焦点,还提供 onSelectAfterClose 让"选中后要把焦点交给编辑器"的菜单项在 surface 卸载后执行动作(MenuItemAfterClose)。注释点明风险:portal 清理期间的待定焦点操作可能指向已分离的元素,因此动作放在 queueMicrotask 里并检查 epoch。
8.2 统一的视觉标记
适配层还给各部件挂上 data-cuelume-* 与统一 class,使声音反馈和样式不必理解 Kobalte 内部结构:
| 标记 | 出现位置 |
|---|---|
data-cuelume-tap |
Button 默认触发器 |
data-cuelume-open / data-cuelume-close |
弹层触发与关闭按钮 |
data-cuelume-select |
菜单项、Tab、Select/Combobox/Listbox 选项 |
data-cuelume-toggle |
Checkbox 菜单项 |
data-cuelume-type |
Combobox 输入框 |
ui-dialog-overlay / ui-dialog-content / ui-action-menu |
对应表面 |
Dialog.Content 还额外处理了外部交互:如果 onInteractOutside 的目标位于 [data-kb-top-layer] 内,则 preventDefault(),避免叠加表面互相误关。
九、状态细节:加载中的按钮与稳定标签
README 之外,button.tsx 提供了两个值得借鉴的实现,它们体现了"共享原语要处理消费端容易漏掉的状态"。
加载态不依赖原生 disabled。 guardLoading 在捕获阶段拦截 click 与 keydown(Enter/Space),这样按钮仍保留键盘焦点,但不会触发调用方处理器,也不会触发表单提交:
function guardLoading(target: HTMLElement): void {
setElement(target);
const block = (event: Event): void => {
if (!props.loading) return;
event.preventDefault();
event.stopImmediatePropagation();
};
target.addEventListener("click", block, { capture: true });
target.addEventListener("keydown", (event) => {
if (ACTIVATION_KEYS.has(event.key)) block(event);
}, { capture: true });
}
同时用 aria-disabled、aria-busy、data-disabled 三个属性分别表达"不接受激活""正在忙""外观禁用"(button.tsx)。
标签切换不撑破布局。 StableLabel 让所有候选文案占同一网格单元,只有激活的可见,因此"复制 → 已复制"不会把旁边的文字挤到下一行;CopyButton 在此基础上加 1.5 秒复位与 aria-live="polite" 播报(button.tsx)。这类细节正是"一份实现"相对三份复制粘贴的价值所在。
十、验证方式:Storybook、a11y 门禁与聚焦检查
README 的验证要求分三层:
- 隔离组件在 Storybook 的
Foundations下验证。 仓库里已有Button、Badge、Forms、Select、Slider、Progress、Dropdown Menu、ConfirmDialog、Interactions、Colors等 story,标题统一以Foundations/开头(例如 Button.stories.tsx)。 - 完整流程在 dev 应用中验证:对话框、菜单、编辑器与会话不适合拆成孤立 story。
- a11y 是全局 Storybook 门禁,在
preview.tsx里配置为test: "error"(.storybook/preview.tsx),main.ts加载@storybook/addon-a11y。
Storybook 的配置也解释了为什么共享包能被正确解析:main.ts 把 solid-js/web 别名到 @solidjs/web,对 solid-js 与 @solidjs/web 做 dedupe,并移除遗留的 solid 插件后挂上 @solidjs/vite-plugin,同时启用 Tailwind 的 vite 插件(.storybook/main.ts)。这与第三节的 Tailwind 扫描要求是同一件事的两面。
组件状态覆盖要求也很具体:默认、hover、focus-visible、active、disabled、loading/空态(适用时),外加一个键盘测试与一个 a11y story。
十一、接入或新增组件时的检查清单
把 README 与 AGENTS.md 的约束整理成可执行顺序:
- 确认归属:这个组件是否已被两个以上消费端需要?若是,进
packages/ui/src/features/<domain>/;若只是某个应用独有,留在src/renderer/src/features/<domain>/。 - 不复制,只迁移:保持既有 markup、样式与行为不变,除非明确要求改设计。
- 数据走 props,动作走回调:类型来自
@openbot/contracts或packages/ui/src/data.ts;不要读应用上下文、不要window.openbot、不要 importsrc/renderer、src/main、src/backend、src/preload。 - 文本走
useText():不新增tprop,不写死界面文案。 - 原语从
@openbot/ui取,图标从./icons取:不直接 import Kobalte 或 Lucide;index.ts是唯一 barrel,特性内部相对导入。 - 导出加进
package.json的exports:特性用显式子路径,样式用独立子路径。 - 样式进有序清单:
conversation.css这类清单只追加不重排;新分片放styles/下并被清单引用。 - 颜色与尺寸用令牌:语义化
--openbot-*,hover 只在@media (hover: hover) and (pointer: fine)内生效。 - 补 story 与状态:
Foundations/*或对应特性标题,覆盖键盘与空/加载态。 - 跑聚焦检查:
bun run check:ui、相关typecheck,以及受影响的 Storybook。仓库规定check:ui由 pre-commit 钩子在暂存代码时运行,预算只降不升。
packages/ui/AGENTS.md 还提示了一个容易忽略的消费端范围:改动共享组件后要检查桌面端、公开 Web、预览与 Storybook 四类消费方,因为同一份实现会同时影响它们。
十二、边界与限制
- 版本处于预发布通道:
solid-js@2.0.0-rc.18、@kobalte/core@2.0.0-alpha.0,并依赖patches/下的本地修复。上游一旦切到稳定 API,应先移除补丁,再在不改特性导入的前提下确认适配层行为。 - 不能假设
solid-js/store可用:src/renderer/AGENTS.md记录该 RC 从"solid-js"导出 store,且Map/Set不能成为 store。 packages/ui不是通用 npm 组件库:private: true、version: 0.0.0,只在 workspace 内使用。- 移动端不消费这套 UI:它共享
@openbot/brand的令牌与@openbot/contracts契约,SolidJS DOM 组件对它没有意义。 - 自动检查有明确盲区:GritQL 无法把 story 的测试查询与产品的
data-testid属性连起来,所以由check:ui用计数兜底;这也解释了为什么data-testid预算必须是 0。
理解这三件事——显式子路径导入、按序装载样式、把应用与平台能力挡在包外——就能在不破坏 OpenBot 三个消费端的前提下复用或扩展 @openbot/ui。需要继续深入的入口是 packages/ui/AGENTS.md(包内规则)、docs/ui-foundation.md(令牌、公开 API 与检查项)、docs/i18n.md(界面文本)以及 scripts/ui-foundation-check.ts(边界的具体实现)。