跳到主要内容

OpenUI (OpenUI Lang) — 架构与原理

30 秒导读: OpenUI 是一个「生成式 UI」框架。它的核心是一门叫 OpenUI Lang 的小语言——你让大模型用这门语言(而不是 JSON 或纯文本)回话,前端一边接收 token 一边解析、一边把界面画出来。比起 JSON,它的 token 用量少约一半;而且语言里内置了「变量、数据查询、按钮动作」,所以模型吐出来的不是死的界面截图,而是一个会自己取数、能交互、能被增量编辑的活页面。

本仓库克隆目录名是 crayon,但它实际就是 thesysdev/openui(npm 组织 @openuidev/*)。下文统一称 OpenUI


1. 这是什么(零基础也能懂)

一句话定义

OpenUI 是一套全栈生成式 UI 框架:一门为流式输出设计的紧凑 UI 语言(OpenUI Lang)、一个把这门语言边流边渲染成 React 界面的运行时、外加几套现成的组件库与聊天外壳。

它解决谁的什么问题

假设你在做一个 AI 助手,希望模型的回答不只是一段文字,而是一张图表、一个表格、一个可填写的表单。你有两条老路:

  • 让模型吐 JSON,前端再把 JSON 映射成组件。问题:JSON 又啰嗦(键名重复、引号括号一大堆)又费 token,流式时还很难「半个 JSON」就渲染。
  • 让模型直接吐 HTML/JSX。问题:不可控、容易 XSS、组件不受你约束。

OpenUI 给的是第三条路:定义一个你允许模型使用的组件库 → 由这个库自动生成一段系统提示词 → 模型按提示词用 OpenUI Lang 回话 → 运行时边流边渲染。给谁用?需要做 AI 聊天/copilot/仪表盘前端,又想把模型输出限定在自己组件集里的工程师。

它能做什么

  • 结构化、可流式的 UI 生成(token 比等价 JSON 少约 50%)。
  • 受控渲染:模型只能用你注册过的组件,不认识的组件直接被丢弃。
  • 活数据:语言里有 Query()(读)和 Mutation()(写),界面能自己取数、定时刷新、按钮触发写操作。
  • 交互与响应式:$变量 做双向绑定,下拉框一改,相关 Query 自动重取。
  • 增量编辑:模型只吐「改了哪几行」,运行时按语句名合并,未触及的部分自动保留。
  • 多框架:React / Vue / Svelte 绑定,外加 CDN bundle。

用起来什么样

模型不再回一段 markdown,而是回这样一段 OpenUI Lang(# 示意,展示语言长相):

root = Card([header, chart])
header = TextContent("本周销量")
sales = Query("get_sales", {days: 7}, {rows: []})
chart = BarChart(sales.rows.label, sales.rows.value)

读法:

  • 每行是一条 名字 = 表达式 语句;root 是入口。
  • CardBarChart 是你库里定义的组件;参数按位置传(不是按名字)。
  • sales = Query(...) 声明一次数据获取;sales.rows.value 把每行的 value 字段「拔」成一个数组喂给图表。
  • 模型先吐 root,界面外壳立刻出现;chart 还没流到时先空着,流到后补上——这就是「渐进式 reveal」。

一句话直觉 / 类比

把它想成**「给大模型用的、专为流式设计的 JSX 方言」**:像 JSX 一样声明组件树,但语法砍到最瘦(省 token)、加了变量和数据查询(让界面活起来)、且每个 token 流进来都能被「半句也能渲染」地解析。


2. 顶层全景(它大概怎么转)

一条主线:从组件库到活界面

你写的组件库(Zod schema)
│ library.prompt()

① 系统提示词 ───────────────► ② 大模型
(组件签名+语法规则+Query教学) │ 流式吐 OpenUI Lang

┌──────────────────────┐
library.toJSONSchema() ───►│ ③ 流式解析器 │ 每来一个 chunk
(告诉解析器每个组件的参数) │ push(chunk)→ParseResult│ 重算一次
└──────────┬───────────┘
│ root: ElementNode 树

┌──────────────────────┐
toolProvider(取数后端) ─►│ ④ 运行时 │
store(响应式 $变量) │ evaluator 求值 AST │
│ queryManager 取数缓存 │
└──────────┬───────────┘
│ 求值后的 props 树

⑤ React Renderer → 活界面

怎么读这张图: 上半部分是「离线一次性」——库 → 提示词 → 模型。下半部分是「每个 token 都在跑」——解析 → 求值 → 渲染。两条信息从库里分叉:prompt() 喂给模型,toJSONSchema() 喂给解析器,它们必须对得上(同一个库)。

部件一句话职责

部件干什么在哪个文件
Library用 Zod 定义组件,产出提示词 + JSON Schemapackages/lang-core/src/library.ts
Prompt generator把库反向工程成系统提示词packages/lang-core/src/parser/prompt.ts
Lexer + Parser词法分析 + 把每条语句解析成 ASTpackages/lang-core/src/parser/lexer.tsparser.ts
Materialize把 AST 降级成 ElementNode 树,顺手校验packages/lang-core/src/parser/materialize.ts
Stream parser增量喂 chunk,每次返回最新 ParseResultpackages/lang-core/src/parser/parser.ts(createStreamParser)
Evaluator运行时把 AST 节点求值成真实值packages/lang-core/src/runtime/evaluator.ts
QueryManager执行 Query/Mutation、缓存、刷新、错误packages/lang-core/src/runtime/queryManager.ts
Store响应式 $变量 状态packages/lang-core/src/runtime/store.ts
RendererReact 适配:把 ElementNode 画成组件packages/react-lang/src/Renderer.tsx

仓库布局(monorepo)

核心全在 packages/lang-core(框架无关、无 React 依赖)。其余包都是它的「外壳」:

作用
@openuidev/lang-core解析器 + 提示生成 + 运行时求值 + 类型(本文主角)
@openuidev/react-langReact 渲染运行时(Renderer)
@openuidev/react-headless无头聊天状态 + 流式适配器
@openuidev/react-ui现成聊天布局 + 两套内置组件库
@openuidev/vue-lang / svelte-langVue / Svelte 绑定
@openuidev/cli(目录名 packages/openui-cli)脚手架 + 从库生成提示词/JSON Schema

3. 阅读地图(建议顺序)

本项目复杂度集中在「一门小语言 + 它的流式解析 + 它的提示生成 + 它的运行时」四块,因此拆成四章,由浅入深:

  1. 01-language-and-grammar.md — 先认识这门语言本身:有哪些语句、表达式、$变量Query@内置函数,以及「词法 → Pratt 表达式解析 → 语句分类」三步怎么把文本变成 AST。先读这章,后面才有共同词汇。

  2. 02-streaming-parser.md — 这门语言最与众不同的地方:为流式而生。讲 autoClose 怎么把半句补全、增量解析器怎么用 watermark 只处理新增、materialize 怎么把 AST 降级成 ElementNode 并做校验、以及 hoisting(可前引用)如何造就「结构先出、数据后填」的渐进式 reveal。

  3. 03-prompt-generation.md — 反方向的一章:从组件库自动生成系统提示词。讲 Zod schema 怎么内省成 Card(children, title?) 这种签名、提示词由哪些段落拼成、toolCalls/bindings 等特性开关如何按需裁剪整段教学。这是整个框架「让模型听话」的关键。

  4. 04-runtime-evaluation.md — 最后是「活起来」的一章:运行时求值。讲 evaluator 怎么算 sales.rows.value 这种表达式、queryManager 的取数/缓存/刷新/错误模型、store 的响应式 $变量、Action 把按钮点击编排成有序步骤、以及 edit-mode 按语句名合并 + 垃圾回收。

如果你只想抓主线:01 → 02 已经能让你讲清「OpenUI Lang 是什么、为什么能流式」。要讲「模型为什么会按规矩输出」读 03,要讲「界面如何取数交互」读 04。


4. 巧妙之处速览(细节见各章)

  • 解析永不抛错。 无论输入多残缺(半个字符串、缺括号),autoClose 都先补成合法,再解析——流式途中 UI 不闪不崩(parser/statements.ts:autoClose)。
  • 提示词和解析器同源。 提示词的组件签名 与 解析器用的 ParamMap 都来自同一个 Zod 库,从结构上保证「模型被教的」和「解析器认的」一致(library.ts:createLibrary)。
  • 结构化错误喂回模型。 解析/运行时错误是带 code/hint/statementIdOpenUIError,专为「让模型自己改」的纠错循环设计(parser/types.ts:OpenUIError)。
  • 省 token 是设计目标而非副产物。 位置参数(不写键名)、引用复用、@Each 模板,都是为「同样的 UI 更少 token」服务(README benchmarks:基准 TOTAL 行 −52.8% vs Vercel JSON;README 头条另写的「up to 67%」是单项峰值,口径不同)。

5. 横向对比(同 area 兄弟)

OpenUI 属于 agent-ui area。与几个兄弟项目的取舍差异:

项目它解决的「生成式 UI」是什么取向
OpenUI(本项目)发明一门紧凑 DSL 让模型直接吐 UI,核心价值在「语言 + 流式解析 + 提示生成」
assistant-ui偏「聊天运行时 + 可组合 primitives」,生成式 UI 是把工具调用渲染成 React 组件,不发明语言
copilotkit偏「把 agent 接进 React 应用」,强调状态共享与工具执行,UI 由开发者写 React
chat-ui偏「现成聊天前端」,关注完整产品形态而非底层 UI 协议

一句话:别人多是「把模型输出接到 React」,OpenUI 是「先发明一门给模型说的 UI 语言,再为它造解析器和运行时」。 详见各兄弟 doc。