OpenBot 共享 UI 层实战:@openbot/ui 的导入约定、样式装载与边界设计

原创2026-10-27 22:02:252,212 阅读
文章标签:桌面应用AI Agent人工智能多智能体代码智能体浏览器控制本地部署MCP 服务

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 是整个包的公开契约。它分成三类:

  1. 主入口 . → ./src/index.ts,只转发基础原语(alert、badge、button、checkbox、select、toast、typography、user-avatar 等),见 src/index.ts;
  2. 显式特性子路径,例如 ./features/sidebar/Sidebar、./features/conversation/ComposerEditor、./features/account/AccountLogin、./features/servers/AddServerDialog;
  3. 样式子路径,例如 ./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.css and @openbot/ui/styles.css in the consumer stylesheet. Tailwind consumers must scan packages/ui/src.

3.1 三条装载规则

  1. 先装令牌:@openbot/brand/tokens.css 是全局 --openbot-* 变量的唯一声明处,深色为基准,浅色在 @openbot/brand/tokens-light.css 里只改值、不新增变量(见 docs/ui-foundation.md)。
  2. 再装原语样式:@openbot/ui/styles.css 实际指向 ./src/styles/primitives.css,这是 Button、Text、Dialog 等原语的规则来源。
  3. 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, or src/preload.

packages/ui/AGENTS.md 把它进一步具体化:不要在包内调用 window.openbot,不要导入渲染器、主进程、后端或预加载代码;移动组件,不要复制组件。

6.1 唯一例外:界面文本

packages/ui/AGENTS.md 明确写了唯一的应用上下文例外——界面文本:

The one exception is interface text: call useText() from @openbot/ui/text for t, format and errorMessage. Do not add a t prop.

原因在 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):

  1. 打开时记录 document.activeElement 作为恢复目标;
  2. 关闭时查找 [aria-haspopup][aria-expanded="true"][aria-controls],若其控制的元素是 dialog/alertdialog/menu,就把触发元素作为恢复目标;
  3. 只有"焦点确实由该表面拥有"(焦点在 body、在恢复目标上、或在对话框内)才恢复,用户主动点进输入框的焦点不会被抢走;
  4. 恢复动作放在双层 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 的验证要求分三层:

  1. 隔离组件在 Storybook 的 Foundations 下验证。 仓库里已有 Button、Badge、Forms、Select、Slider、Progress、Dropdown Menu、ConfirmDialog、Interactions、Colors 等 story,标题统一以 Foundations/ 开头(例如 Button.stories.tsx)。
  2. 完整流程在 dev 应用中验证:对话框、菜单、编辑器与会话不适合拆成孤立 story。
  3. 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 的约束整理成可执行顺序:

  1. 确认归属:这个组件是否已被两个以上消费端需要?若是,进 packages/ui/src/features/<domain>/;若只是某个应用独有,留在 src/renderer/src/features/<domain>/。
  2. 不复制,只迁移:保持既有 markup、样式与行为不变,除非明确要求改设计。
  3. 数据走 props,动作走回调:类型来自 @openbot/contracts 或 packages/ui/src/data.ts;不要读应用上下文、不要 window.openbot、不要 import src/renderer、src/main、src/backend、src/preload。
  4. 文本走 useText():不新增 t prop,不写死界面文案。
  5. 原语从 @openbot/ui 取,图标从 ./icons 取:不直接 import Kobalte 或 Lucide;index.ts 是唯一 barrel,特性内部相对导入。
  6. 导出加进 package.json 的 exports:特性用显式子路径,样式用独立子路径。
  7. 样式进有序清单:conversation.css 这类清单只追加不重排;新分片放 styles/ 下并被清单引用。
  8. 颜色与尺寸用令牌:语义化 --openbot-*,hover 只在 @media (hover: hover) and (pointer: fine) 内生效。
  9. 补 story 与状态:Foundations/* 或对应特性标题,覆盖键盘与空/加载态。
  10. 跑聚焦检查: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(边界的具体实现)。

登录后查看全文
openbot