Dify 的 @langgenius/dify-ui:基于 Base UI 与 Tailwind CSS v4 的私有 UI 原语包
@langgenius/dify-ui 是 Dify 仓库 packages/dify-ui 目录下的一个私有工作区包,为 Dify 各前端产品提供独立的 UI 原语、设计令牌、CSS-first 的 Tailwind v4 样式和 cn() 工具函数。阅读本文可以掌握该包的引入方式、全部公开子路径导出的原语清单、基于 Base UI 的包装策略与 cva/设计令牌体系,以及围绕该包建立的文档契约、样式规范与测试体系,从而在 Dify 前端代码中正确选用、组合这些 UI 原语。
1. 包定位:独立原语包与“薄包装”策略
根据包索引 README 的说明,@langgenius/dify-ui 的定位是:
- 提供 独立的 UI 原语(independent UI primitives)、设计令牌(design tokens)、CSS-first 的 Tailwind 样式以及
cn()工具,服务对象是 Dify 的全部产品线; - 大多数交互式原语是 Base UI 无头(headless)组件的薄且带立场(thin, opinionated)的包装器——即 Dify 只在其上叠加自己的外观、变体与细节行为,交互逻辑仍归上游所有;
- Dify 自研的原语统一使用语义化 HTML、
cva(class-variance-authority)、cn和 Dify 设计令牌来构建; - 包本身对 workspace 私有(
private: true),但它的公开子路径(public subpaths)被视为稳定的包边界——消费方可以依赖这些子路径,而包内部实现可以自由演进。
从源码结构看,这一策略在 package.json 中得到印证:
- 包名为
@langgenius/dify-ui,version为0.0.1,private: true; exports字段显式声明了./styles.css、./cn以及 30+ 个原语子路径(如./button、./dialog、./select),每个子路径分别给出types与import两个条件,均指向src/<name>/index.tsx;peerDependencies声明了@base-ui/react、class-variance-authority、react、react-dom、tailwindcss五个对等依赖,说明交互能力、变体系统、样式引擎都刻意外置给消费方统一提供;- 依赖里唯一的运行时依赖是
cn(workspace 版本),对应实现见 src/cn.ts,其中仅有一行export { cn } from 'cn'——即cn复用工作区共享实现,本包只做再导出。
包边界规则集中写在 AGENTS.md 中,核心约束包括:
- 保持独立原语包身份,不得导入应用包,不得依赖路由、i18n、应用状态、schema、数据获取或业务 API;
- 需要无头行为时优先使用
@base-ui/react,用cva、cn与设计令牌做外观,每个原语一个src/<name>/目录,stories 与测试就地放置; - 优先使用 Base UI 的 data 属性与 CSS 变量表达视觉状态,不得仅为加 class 而在 React 侧镜像原语状态;
- 上游 API 或选择器契约不明确时,先读当前官方 Base UI 文档与已安装的
@base-ui/react类型声明再编码。
2. 使用方式:workspace 依赖 + 无根桶导出
包索引 README 给出标准接入方式:在消费方的 package.json 中声明 workspace 依赖:
{
"dependencies": {
"@langgenius/dify-ui": "workspace:*",
},
}
导入必须走公开子路径。该包刻意不提供根桶(root barrel)导出,目的是让每个原语成为一条独立、可追踪的模块边界:
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Input } from '@langgenius/dify-ui/input'
import { cn } from '@langgenius/dify-ui/cn'
import '@langgenius/dify-ui/styles.css'
样式入口 styles.css 需要在消费方的根样式表或入口点处只导入一次(配合 Tailwind v4 的接入方式见第 6 节)。
与 authoring 指南 呼应的是:包内部组件之间可以相对导入,消费方只允许通过公开子路径导入;每个公开原语都要求 package.json#exports 中存在匹配的子路径。
3. 公开原语总览
包索引 README 的完整原语分类表如下(与 package.json 的 exports 一一对应):
| 分类 | 公开子路径 |
|---|---|
| Actions | ./button、./icon-button、./toggle |
| Controls | ./segmented-control |
| Display | ./collapsible、./kbd |
| Feedback | ./meter、./progress、./status-dot、./toast |
| Form | ./form、./field、./fieldset、./input、./input-group、./textarea、./checkbox、./checkbox-group、./radio-group、./number-field、./select、./slider、./switch |
| Layout | ./scroll-area |
| Media | ./avatar |
| Navigation | ./file-tree、./pagination、./tabs |
| Overlay and menu | ./alert-dialog、./context-menu、./dialog、./drawer、./dropdown-menu、./popover、./preview-card、./tooltip |
| Search and pick | ./autocomplete、./combobox、./select |
两个通用工具子路径:
./cn:基于clsx+tailwind-merge组合条件类名;./styles.css:提供设计令牌、主题变量与共享工具类。
从目录结构看,src/ 下每个原语目录都遵循统一布局:index.tsx(公开 API 边界)、index.stories.tsx(Storybook 契约示例)、__tests__/index.spec.tsx(单元测试);仅 button、icon-button、input-group 三个组件额外拥有本地 README,对应下文的组件级契约。此外还有跨原语共享的模块,如 src/form-control-shared.ts、src/overlay-shared.ts 与 src/internals/use-iso-layout-effect.ts。
4. 以 Button 为例:cva 变体体系与 Dify 立场
Button 契约 是本包“薄包装 + Dify 立场”的最佳样本。其语义约定:
- 有可见文本标签的动作使用
Button;图标按钮用IconButton;持久按压态用Toggle;导航到 URL 一律用原生链接,不通过Button渲染链接,只复用其视觉变体:
<a className={buttonVariants({ variant: 'secondary' })} href="/settings">
Settings
</a>
Button默认渲染<button type="button">,表单提交时必须显式type="submit";render配合nativeButton={false}仅用于“非 button 元素刻意需要按钮语义”的场景,不是链接模式。
在 src/button/index.tsx 中可以看到这套契约的落地:buttonVariants 用 cva 定义了三个维度——
variant:primary/secondary/secondary-accent/tertiary/ghost/ghost-accent;size:small(h-6,gap 3px 级)、medium(h-8,默认)、large(h-9);tone:default/destructive,并针对每个 variant 通过compoundVariants给出 destructive 专属令牌;defaultVariants为variant: 'secondary'、size: 'medium'、tone: 'default'。
所有视觉状态都通过设计令牌类名(如 bg-components-button-primary-bg、data-disabled:text-...)表达,并依赖 Base UI 的 data-disabled 等 data 属性而非 React 状态镜像——这正是 AGENTS.md 边界规则在实现层面的体现。ButtonProps 由 Omit<BaseButtonNS.Props, 'className'> 叠加 VariantProps 与 Dify 自有的 loading、className 构成,className 在包装层统一经 cn() 消费。
loading 与 disabled 的区分
Button README 用一张表明确区分了两个事实:
| Prop | 含义 | 默认焦点行为 |
|---|---|---|
disabled |
动作不可用。 | 原生禁用,移出 Tab 序。 |
loading |
动作已被触发、正在等待。 | 等待期间阻止激活,但按钮保留焦点。 |
内部映射为:
disabled={disabled || loading}
focusableWhenDisabled={focusableWhenDisabled ?? loading}
也就是说,loading 期间按钮以 aria-disabled 保留可聚焦性而不是原生 disabled,防止“触发后焦点丢失”;调用方只需把等待状态传给 loading,把独立的可用性条件留在 disabled:
<Button loading={isSaving} disabled={!canSave}>
Save
</Button>
契约还明确禁止把同一等待态重复写进 disabled,并说明 loading 与独立的 disabled 同时为 true 是合法的;只有当希望 loading 按钮退出 Tab 序时才显式传 focusableWhenDisabled={false}。另一个可访问性立场是:spinner 是装饰性的,Button 不添加 aria-busy(该属性的 WAI-ARIA 定义针对“内容可能被辅助技术延迟修改”的元素,不是待处理动作的通用替代);长任务的播报/进度由功能侧自己的状态或 live region 负责。
内容间距契约:Button 自己拥有直接子元素之间的间距,调用方不要给图标加 margin 或标准 gap-*;medium/large 分别为 4px/6px,small 在 primary 下 3px、其余变体 4px。
IconButton 与 Input Group 契约
另外两份组件级契约分别是 IconButton 与 Input Group:
- IconButton:用于“一个图标、无可见文本”的命令。必须且只能提供一个可访问名称来源(
aria-label或aria-labelledby),tooltip 只是视觉增强、不构成可访问名称;glyph 作为单个装饰性 React 元素传入并aria-hidden,glyph 自己负责光学尺寸,IconButton负责尺寸、圆角、颜色、hover、disabled 与 focus-visible 外观。当 Toggle、Menu、Popover、Tooltip、Collapsible 拥有交互状态时,把状态原语放在外层、通过renderprop 组合IconButton,而不是把状态镜像到图标按钮上。 - InputGroup:当一个文本输入要与前缀/后缀/动作共享同一视觉表面时使用。结构要求恰好一个直接的
InputGroupInput加一个或多个直接的InputGroupAddon;InputGroup拥有边框、背景、聚焦态与非交互指针表面,InputGroupInput拥有原生输入与值。DOM 顺序上输入必须位于所有 addon 之前,视觉位置用align="inline-start"/"inline-end"表达,不改变语义与焦点顺序。点击非交互表面会把焦点移给直接输入,点击交互 addon 则只作用于该控件本身;共享表面行为可通过InputGroup的onMouseDown中preventDefault()取消。需要名称、标签、校验、描述或错误时,外层包Field,其状态会传播到InputGroupInput。
5. 样式体系:styles.css、主题与设计令牌
CSS 入口 src/styles/styles.css 是 Tailwind v4 意义上的“preset(CSS-first)”,文件头注释明确:它是消费方唯一的 CSS 入口(@import '@langgenius/dify-ui/styles.css'),且消费方必须在自己的根样式表中 @import 'tailwindcss' 让引擎生成工具类——该文件只贡献设计令牌、运行时 CSS 变量与项目工具类。入口的导入结构为:
@import '../themes/theme.css';
@import '../themes/light.css' layer(base);
@import '../themes/dark.css' layer(base);
@import './utilities.css';
@import './components.css';
随后用 @theme 覆盖 Tailwind v4 的默认 oklch 调色板,把 bg-gray-500、text-primary-600 等标准类名解析到 Dify 品牌色板(例如 --color-gray-500: #667085、--color-primary-500: #2970ff),从而让“标准 Tailwind 类名 = 设计系统取值”。主题变量分别放在 src/themes/theme.css、light.css 与 dark.css,工具类与组件级样式则拆在 utilities.css 和 components.css 中。
Styling 指南 补充了消费方的完整接入步骤与两条硬性规范:
- 消费方样式表接入(
@source路径相对消费方样式表解析):
@import 'tailwindcss';
@import '@langgenius/dify-ui/styles.css';
若 workspace 消费方需要直接扫描 Dify UI 源码,追加 @source:
/* Example only: resolve paths from this stylesheet. */
@source '../../../packages/dify-ui/src';
@source not '../../../packages/dify-ui/src/**/*.{spec,test}.{ts,tsx}';
@source not '../../../packages/dify-ui/src/**/*.stories.{ts,tsx}';
- Figma 圆角令牌映射:Figma 圆角令牌与 Tailwind v4 默认值错开一档,必须按下表转换,而不是新增自定义主题值或
radius-*工具类:
| Figma 令牌 | Tailwind 类 |
|---|---|
--radius/2xs |
rounded-xs |
--radius/xs |
rounded-sm |
--radius/sm |
rounded-md |
--radius/md |
rounded-lg |
--radius/lg |
rounded-[10px] |
--radius/xl |
rounded-xl |
--radius/2xl |
rounded-2xl |
--radius/3xl |
rounded-[20px] |
--radius/6xl |
rounded-[28px] |
--radius/full |
rounded-full |
例如 Figma 输出的 rounded-[var(--radius/sm, 6px)] 应转换为映射表中的 Tailwind 类;只有没有标准类匹配时才允许任意值。指南同时要求:优先用语义 Dify 令牌与既有组件变体,其次才考虑硬编码;important 修饰符只用于“所有权的变体、data 属性与选择器结构都无法表达该状态”时的紧凑兼容覆盖;focus-visible 样式要挂在视觉上代表焦点的元素上(例如 SliderThumb 因内部 range input 接收焦点而使用 has-[:focus-visible])。
6. 文档体系:组件指南与跨组件契约
包索引 README 的 Guides 章节给出了文档导航原则:从这里开始,然后只打开正在改动的那个契约对应的指南;组件特定的 Dify 行为放在组件旁边,多个原语共享的契约放在 docs/,上游行为归 Base UI 官方文档所有。
组件级指南(Dify 自有契约):
| 指南 | Dify 自有契约 |
|---|---|
| Button | 动作语义、submit 与链接的选择、loading 与 disabled 的区分、内容间距。 |
| Icon Button | 可访问名称、装饰性 glyph、外观所有权、原语组合。 |
| Input Group | 复合输入解剖、共享表面所有权、DOM 顺序、焦点、交互 addon。 |
跨组件指南(均在 packages/dify-ui/docs/ 下):
| 指南 | 范围 |
|---|---|
| Accessible names and descriptions | 命名来源、描述、覆盖、安全地移除 label。 |
| Forms | 原生提交边界、值所有权、字段、标签与错误。 |
| Selection | 带类型的值,以及在分段控件、选择器与单选组之间的选择。 |
| Overlays | Portal、presence 生命周期、层级、触发器组合与语义。 |
| Styling | Tailwind CSS 集成与 Figma 圆角映射。 |
| Public API authoring | 子路径导出、命名、公开类型、泛型、私有辅助。 |
| Testing and development | 包命令、测试所有权、可访问性、动画配置。 |
公开 API 编写规范要点
Public API authoring 是新增原语前必读的契约,关键规则:
- 每个
src/<primitive>/index.tsx就是显式公开 API 边界,实现细节保持模块私有,在文件底部用独立的export { ... }与export type { ... }清单统一发布,禁止散落的内联导出和通配符导出; - 命名上以原语名作为规范边界与 props 类型名(
Select/SelectProps),仅当一个子路径同时导出低层解剖与高层便捷组件时才保留Root(如CheckboxRoot与Checkbox); - 每个运行时组件必须有精确、可导入的同名 props 类型;Dify 自研的复合 props 定义在 Dify UI 边界上,而不是复制上游形状;受控/非受控、单选/多选等“一个 prop 改变其余 props 合法形状”的场景用可辨识联合;
- 泛型契约要端到端保持(选择器
Value/Multiple、表单值、radio 与 slider 值、overlay 负载与句柄),不得用any或硬编码string抹掉调用方类型;Tabs因上游值类型是any | null而刻意保持非泛型根——“不要宣传完整解剖无法强制的类型关系”; - 保持公开面最小:类型不因“Base UI 有这个名字”或“实现曾经导出过”而自动公开;状态、事件细节、actions、受控态辅助、context 值、渲染/样式辅助与上游透传别名默认私有;包装器若已通过
cn()消费className,就省略上游的状态回调形式,公开className?: string。
测试与开发
Testing and development 定义了可复制的命令面。从仓库根目录运行 vp check packages/dify-ui 做格式化、lint 与 TypeScript 诊断;其余命令在 packages/dify-ui/ 下运行(对应 package.json 的 scripts):
vp test --project unit:运行原语单元测试;vp run storybook:启动 Storybook;vp test --project storybook --run:以浏览器模式运行 Storybook 组件测试;vp test:运行两个测试项目。
测试边界的设计值得借鉴:包内有两个 Vitest 项目(unit 与 storybook),都在 Playwright Chromium 的 Browser Mode 中运行,项目名标识行为所有者而非不同运行时。Storybook 用于“有文档的组件示例”,每个 story 都是一条渲染契约并跑配置好的可访问性检查;当示例还承担可见状态变化、用户交互、键盘路径、overlay 流程、表单行为、loading 行为或受控态协同时才添加 play 测试。更底层的包装契约(类变体、Base UI 透传 props、隐藏输入序列化、data 属性钩子、store、无需文档示例的边缘情况)用普通 Vitest 测试覆盖。
Storybook 可访问性测试使用 a11y.test = 'error',违规即失败;颜色对比是唯一全局禁用的规则(已知的设计令牌缺口),并明确禁止再添加全局例外——临时例外要局部限制在受影响的 story 内,且不能用 play 测试替代可访问性修复。
动画配置上,Base UI 可以在卸载过渡组件前等待 element.getAnimations();当测试断言最终 DOM 状态而非动画行为时,在 Vitest setup 中置位:
;(
globalThis as typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
vitest.setup.ts 已为原语测试应用该标志;Storybook 使用自己的 preview 配置并保留真实动画生命周期。刻意断言动画行为的单元测试可以临时恢复为 false,但必须在清理时还原旧值。
7. 修改边界:贡献前必读
包索引 README 的 Contributing 章节只有一条指令:修改包之前先读 component authoring rules,然后只打开对应的契约所有者指南——这份索引刻意不重复那些契约。结合 AGENTS.md 的 Contract owners 章节,改动路由关系为:
- 导入、导出、命名、公开类型、泛型与解剖 → Public API authoring
- Button 与图标动作行为 → Button、Icon Button
- 跨组件可访问名称与描述 → Accessible names and descriptions
- 复合输入行为 → Input Group
- 表单结构、标签与值所有权 → Forms
- 选择器选择与带类型值 → Selection
- Portal、presence、层级与浮动表面语义 → Overlays
- Tailwind 集成与圆角映射 → Styling
- 测试所有权与配置 → Testing and development
另一个明确规则:组件只有在“拥有一份类型、stories 与上游文档都无法表达的、相当体量的 Dify 特有契约”时才配拥有本地 README,不为完整性而创建。
8. 小结
packages/dify-ui 的价值不在单个组件的丰富度,而在一套自洽的工程约束:Base UI 拥有交互、Dify 拥有外观与契约;公开面只通过 package.json#exports 子路径暴露;样式走 Tailwind v4 CSS-first 的设计令牌与 Figma 映射;文档按“契约所有者”拆分到组件旁与 docs/;测试则用 unit 与 storybook 两个浏览器模式项目分别守住“底层契约”与“文档示例契约”。阅读 包索引 README、AGENTS.md 与 docs/ 目录下的各指南,即可完整还原这套体系并安全地扩展新的原语。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00