外观
OpenUI 是一个生成式 UI 库,让语言模型能够以称为 openui-lang 的声明式格式生成完整、可交互的 UI。智能体不是返回聊天消息,而是返回一个组件树,其中包含卡片、图表、表格、选项卡和表单,Renderer 会将其转换为真正的 React UI。
此集成非常适合报告、仪表盘和数据探索器等数据密集型输出,在这种场景中,模型既是数据分析师,又是 UI 设计师。
import { ExampleEmbed } from "/snippets/example-embed.jsx"
工作原理
- 生成系统提示词: 在启动时调用一次
openuiLibrary.prompt();它会生成一份完整的 openui-lang 参考,模型据此编写有效的组件树 - 在第一条消息时注入: 新对话开始时,将系统提示词作为开头的系统消息发送
- 模型编写 openui-lang: 模型返回
root = Stack([header, kpis, chart])这样的程序,而不是叙述文本 - 使用
Renderer渲染: 将文本传给 OpenUI 的Renderer和组件库;它会解析并渲染该组件树
安装
bash
npm install @langchain/react @openuidev/react-ui @openuidev/react-headless @openuidev/react-langTIP
OpenUI 需要 React 19 及以上版本和 zustand。前端代码仅支持 React;LangGraph 智能体后端可以用 TypeScript 或 Python 编写。
导入组件样式
在 CSS 入口点或根组件中直接导入 OpenUI 自带的样式:
css
@import "@openuidev/react-ui/components.css";
@import "@openuidev/react-ui/styles/index.css";生成系统提示词
OpenUI 自带 openuiLibrary.prompt() 函数,可生成完整的 openui-lang 参考,包含所有组件签名、语法规则、流式输出提示和示例。在模块加载时调用一次:
ts
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
// 生成完整的 openui-lang 系统提示词。在启动时调用一次,
// 不要在组件内部调用,以避免在每次渲染时重复计算。
const SYSTEM_PROMPT = openuiLibrary.prompt({
...openuiPromptOptions,
preamble:
"You are a report generator. When asked for a report, produce a detailed, " +
"data-rich report using openui-lang: executive summary, KPI cards, charts, " +
"tables, and multiple sections. Your ENTIRE response must be raw openui-lang " +
"— no code fences, no markdown, no prose.",
});preamble 会覆盖默认的角色设定。添加 additionalRules 以注入特定于任务约束:
ts
const SYSTEM_PROMPT = openuiLibrary.prompt({
...openuiPromptOptions,
preamble: "You are a report generator...",
additionalRules: [
...(openuiPromptOptions.additionalRules ?? []),
"Always end the report with 3–4 follow-up query buttons using " +
"Button({ type: 'continue_conversation' }, 'secondary') inside a " +
"Card([CardHeader('Explore Further'), Buttons([...])], 'sunk').",
],
});通过 useStream 注入系统提示词
将系统提示词作为每个新线程的第一条消息发送。检查 stream.messages.length === 0 以检测新线程并前置一条 system 消息:
tsx
import { useCallback } from "react";
import { useStream } from "@langchain/react";
const SYSTEM_PROMPT = openuiLibrary.prompt({ ... });
export function App() {
const stream = useStream({
apiUrl: import.meta.env.VITE_LANGGRAPH_API_URL ?? "http://localhost:2024",
assistantId: "openui",
});
const handleSubmit = useCallback(
(text: string) => {
// 仅在新线程的第一条消息时注入系统提示词。
// 后续消息的历史记录中已包含该提示词。
const isNewThread = stream.messages.length === 0;
stream.submit({
messages: [
...(isNewThread
? [{ type: "system", content: SYSTEM_PROMPT }]
: []),
{ type: "human", content: text },
],
});
},
[stream],
);
// ...
}使用 Renderer 渲染
将 AI 消息的文本内容连同 openuiLibrary 直接传给 Renderer:
tsx
import { Renderer } from "@openuidev/react-lang";
import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
import { AIMessage } from "langchain";
function MessageList({ messages, isLoading }) {
const lastAiIdx = messages.reduce(
(acc, msg, i) => (AIMessage.isInstance(msg) ? i : acc),
-1,
);
return messages.map((msg, i) => {
if (AIMessage.isInstance(msg)) {
const text = msg.text;
return (
<Renderer
key={msg.id ?? i}
response={text}
library={openuiLibrary}
isStreaming={isLoading && i === lastAiIdx}
/>
);
}
// ... 人类消息气泡
});
}在活跃流式输出期间传入 isStreaming={true},以便 Renderer 在定义陆续到达时优雅地处理未解析的引用。
openui-lang 格式
模型编写的是程序,而不是 JSON 规范。每条语句都是赋值;root 是入口点。官方提示词教会模型这种格式,包括提升式(hoisting)——先写 root,使 UI 外壳立即出现:
root = Stack([header, execSummary, kpis, marketSection])
header = CardHeader("State of AI in 2025", "Comprehensive Analysis")
execSummary = MarkDownRenderer("## Executive Summary\n\nThe AI market reached...")
kpi1 = Card([CardHeader("$826B", "Global Market"), TextContent("42% YoY", "small")], "sunk")
kpi2 = Card([CardHeader("78%", "Adoption"), TextContent("Fortune 500", "small")], "sunk")
kpis = Stack([kpi1, kpi2], "row", "m", "stretch", "start", true)
col1 = Col("Segment", "string")
col2 = Col("Revenue ($B)", "number")
tbl = Table([col1, col2], [["Generative AI", 286], ["ML Infra", 198]])
s1 = Series("Revenue", [286, 198, 147])
ch1 = BarChart(["Gen AI", "ML Infra", "Vision"], [s1])
marketSection = Card([CardHeader("Market Breakdown"), tbl, ch1])启用提升式(推荐)时,root 行会被最先写出,因此页面结构立即出现,每个部分在模型定义时随之填充。
渐进式渲染工具
将 useStream 直接连接到 Renderer 会导致每次流式输出 token 都重新渲染,并产生每个响应的数百次无效重新解析。当图表组件的数据尚未到达时,这会导致它们崩溃。下面的工具解决了这些问题:
| 问题 | 解决方案 |
|---|---|
| 部分字符串字面量 | truncateAtOpenString / closeOrTruncateOpenString — 在解析前丢弃或闭合不完整的字符串 |
| token 中途的频繁更新 | useStableText — 在完整语句边界(name = Expr(…))处才允许 Renderer 更新,而不是每个 token 都更新 |
| 图表空数据崩溃 | chartDataRefsResolved — 在将图表包含到快照中之前,验证图表的 Series 和标签数组已定义 |
还没有 root / 回退 | buildProgressiveRoot — 当模型尚未写出 root 时,从顶层变量合成 root = Stack([…]) |
| snake_case 标识符 | sanitizeIdentifiers — 解析器只接受 camelCase;转换模型发出的任何 snake_case 名称 |
将完整代码块复制到你的项目中,并向 <Renderer> 传入 stable:
tsx
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type ActionEvent,
BuiltinActionType,
Renderer,
} from "@openuidev/react-lang";
import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
/** 去除模型可能生成的任何 markdown 代码围栏。 */
function stripCodeFence(text: string): string {
return text
.replace(/^```[a-z]*\r?\n?/i, "")
.replace(/\n?```\s*$/i, "")
.trim();
}
/**
* openui-lang 解析器只接受 camelCase 标识符。
* 转换模型发出的任何 snake_case 变量名;字符串内容保持不变。
*/
function sanitizeIdentifiers(text: string): string {
const toCamel = (s: string) =>
s.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase());
const snakeVars: string[] = [];
for (const m of text.matchAll(/^([a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+)\s*=/gm)) {
if (!snakeVars.includes(m[1])) snakeVars.push(m[1]);
}
if (snakeVars.length === 0) return text;
let result = "";
let inStr = false;
let i = 0;
while (i < text.length) {
if (text[i] === "\\" && inStr) { result += text[i] + (text[i + 1] ?? ""); i += 2; continue; }
if (text[i] === '"') { inStr = !inStr; result += text[i++]; continue; }
if (!inStr) {
let replaced = false;
for (const v of snakeVars) {
if (text.startsWith(v, i) && !/[a-zA-Z0-9_]/.test(text[i + v.length] ?? "")) {
result += toCamel(v); i += v.length; replaced = true; break;
}
}
if (!replaced) result += text[i++];
} else {
result += text[i++];
}
}
return result;
}
/**
* 遍历文本并跟踪未闭合的字符串。如果文本在字符串中途结束,则截断到
* 最后一个安全换行符——这可以防止部分字符串字面量吞掉
* 我们稍后合成的任何 `root = Stack(…)` 行。
*/
function truncateAtOpenString(text: string): string {
let inStr = false;
let lastSafeNewline = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "\\" && inStr) { i++; continue; }
if (ch === '"') { inStr = !inStr; continue; }
if (ch === "\n" && !inStr) lastSafeNewline = i;
}
return inStr ? text.slice(0, lastSafeNewline) : text;
}
/**
* 与 truncateAtOpenString 类似,但当不完整的行是 TextContent 语句时,
* 会合成一个闭合的 `")`。这样文本可以逐 token 渲染,
* 而所有其他部分字符串行仍会被截断。
*/
function closeOrTruncateOpenString(text: string): string {
let inStr = false;
let lastSafeNewline = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "\\" && inStr) { i++; continue; }
if (ch === '"') { inStr = !inStr; continue; }
if (ch === "\n" && !inStr) lastSafeNewline = i;
}
if (!inStr) return text;
const safeText = lastSafeNewline > 0 ? text.slice(0, lastSafeNewline) : "";
const partialLine = text.slice(lastSafeNewline > 0 ? lastSafeNewline + 1 : 0);
if (/^[a-zA-Z][a-zA-Z0-9]*\s*=\s*TextContent\(/.test(partialLine)) {
return (lastSafeNewline > 0 ? safeText + "\n" : "") + partialLine + '")';
}
return safeText;
}
/** 统计以 `)` 或 `]` 结尾的完整赋值语句行数。 */
function countCompleteStatements(text: string): number {
let count = 0;
for (const line of text.split("\n")) {
const t = line.trimEnd();
if ((t.endsWith(")") || t.endsWith("]")) && /^[a-zA-Z]/.test(t)) count++;
}
return count;
}
const CHART_TYPES = new Set([
"BarChart", "LineChart", "AreaChart", "RadarChart",
"HorizontalBarChart", "PieChart", "RadialChart",
"SingleStackedBarChart", "ScatterChart",
]);
const OPENUI_KEYWORDS = new Set([
"true", "false", "null", "grouped", "stacked", "linear", "natural", "step",
"pie", "donut", "string", "number", "action", "row", "column", "card", "sunk",
"clear", "info", "warning", "error", "success", "neutral", "danger", "start",
"end", "center", "between", "around", "evenly", "stretch", "baseline",
"small", "default", "large", "none", "xs", "s", "m", "l", "xl",
"horizontal", "vertical",
]);
/**
* 当 labels 或 series props 未解析时,(recharts) 图表组件会因 `.map() on null` 而崩溃。
* 在提交稳定快照之前,请确认
* 文本中的每个图表的所有数据变量都已定义。
*/
function chartDataRefsResolved(text: string): boolean {
const lines = text.split("\n");
const complete = new Set<string>();
for (const line of lines) {
const t = line.trimEnd();
const m = t.match(/^([a-zA-Z][a-zA-Z0-9]*)\s*=/);
if (m && (t.endsWith(")") || t.endsWith("]"))) complete.add(m[1]);
}
for (const line of lines) {
const t = line.trimEnd();
const m = t.match(/^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*([A-Z][a-zA-Z0-9]*)\(/);
if (!m || !CHART_TYPES.has(m[2]) || !t.endsWith(")")) continue;
const rhs = t.slice(t.indexOf("=") + 1).replace(/"(?:[^"\\]|\\.)*"/g, '""');
for (const [, name] of rhs.matchAll(/\b([a-zA-Z][a-zA-Z0-9]*)\b/g)) {
if (/^[a-z]/.test(name) && !OPENUI_KEYWORDS.has(name) && !complete.has(name))
return false;
}
}
return true;
}
/**
* 如果模型还没有写出 `root = Stack(…)`,则根据顶层变量
* (已定义但未被任何其他表达式引用的变量)合成一个。
* 这样即使在模型最后才写出 root 的情况下也能进行渐进式渲染。
*/
function buildProgressiveRoot(text: string): string {
if (!text) return text;
const safe = truncateAtOpenString(text);
if (/^root\s*=/m.test(safe)) return safe;
const defs: string[] = [];
const seen = new Set<string>();
for (const m of safe.matchAll(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/gm)) {
if (!seen.has(m[1])) { defs.push(m[1]); seen.add(m[1]); }
}
if (defs.length === 0) return safe;
const referenced = new Set<string>();
for (const line of safe.split("\n")) {
const thisVar = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/)?.[1];
const stripped = line.replace(/"(?:[^"\\]|\\.)*"/g, '""');
for (const v of defs) {
if (v !== thisVar && new RegExp(`\\b${v}\\b`).test(stripped)) referenced.add(v);
}
}
const topLevel = defs.filter((v) => !referenced.has(v));
const rootVars = topLevel.length > 0 ? topLevel : defs;
return `${safe.trimEnd()}\nroot = Stack([${rootVars.join(", ")}], "column", "l")`;
}
/**
* 将 Renderer 的更新限制在至少一个新*完整*语句到达的时刻。
* 这消除了流式输出期间的数百次无效重新解析。
*
* 特殊情况:TextContent 行逐 token 更新(通过 closeOrTruncate),
* 因此文本可以渐进式渲染,无需等待整行完成。
*/
function useStableText(raw: string, isStreaming: boolean): string {
const [stable, setStable] = useState<string>("");
const lastCount = useRef(0);
useEffect(() => {
const safe = truncateAtOpenString(raw); // 严格模式 — 仅用于计数
const enhanced = closeOrTruncateOpenString(raw); // 显示模式 — 闭合不完整的 TextContent
if (!isStreaming) { setStable(enhanced); return; }
const count = countCompleteStatements(safe);
const newComplete = count > lastCount.current && chartDataRefsResolved(safe);
const partialTextContent = enhanced !== safe;
if (newComplete || partialTextContent) {
if (newComplete) lastCount.current = count;
setStable(enhanced);
}
}, [raw, isStreaming]);
return stable;
}
function AIMessageView({
raw,
isStreaming,
onSubmit,
}: {
raw: string;
isStreaming: boolean;
onSubmit: (text: string) => void;
}) {
const stable = useStableText(raw, isStreaming);
const processed = useMemo(() => buildProgressiveRoot(stable), [stable]);
const handleAction = useCallback(
(event: ActionEvent) => {
if (event.type === BuiltinActionType.ContinueConversation) {
onSubmit(event.humanFriendlyMessage);
}
},
[onSubmit],
);
if (!processed) return null;
return (
<Renderer
response={processed}
library={openuiLibrary}
isStreaming={isStreaming}
onAction={handleAction}
/>
);
}
export function MessageList({ messages, isLoading, onSubmit }) {
const lastAiIdx = messages.reduce(
(acc, msg, i) => (msg.getType() === "ai" ? i : acc),
-1,
);
return messages.map((msg, i) => {
if (msg.getType() === "human") {
return (
{msg.text}
);
}
if (msg.getType() === "ai") {
const raw = sanitizeIdentifiers(
stripCodeFence(msg.text),
);
if (!raw) return null;
return (
<AIMessageView
raw={raw}
isStreaming={isLoading && i === lastAiIdx}
onSubmit={onSubmit}
/>
);
}
return null;
});
}后续提问
OpenUI 的 Button 组件支持 continue_conversation 动作类型。当用户点击后续提问按钮时,Renderer 触发 onAction,上面的 AIMessageView 将按钮的标签作为下一条用户消息提交,与在输入框中输入使用完全相同的代码路径。
通过系统提示词中的 additionalRules,为每份报告添加一个 "Explore Further"(继续探索)部分:
followUp1 = Button("Compare AI leaders 2024 vs 2025", { type: "continue_conversation" }, "secondary")
followUp2 = Button("Global AI investment breakdown", { type: "continue_conversation" }, "secondary")
followUpBtns = Buttons([followUp1, followUp2], "row")
followUpCard = Card([CardHeader("Explore Further"), followUpBtns], "sunk")
root = Stack([..., followUpCard])使用 Deep Agents 构建并行仪表盘
上面的流程将一个 OpenUI 程序渲染到一个界面中。对于更丰富的应用,Deep Agents 协调器可以委派给多个专业智能体,每个智能体都通过同一条 useStream 连接并发地流式输出各自的 OpenUI 面板。OpenUI 并行仪表盘示例 将一份仪表盘简报转换为独立流式输出的 Stripe、PostHog、GitHub 和 Calendar 面板,且不需要自定义图或流去多路复用代码。
共享同一个 OpenUI 库
在服务器端(用于生成面板提示词)和客户端(作为 Renderer 的 prop)使用同一个库对象,以便告知模型的组件始终与渲染器能绘制的组件一致:
ts
import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui";
export const library = openuiChatLibrary;
export const promptOptions = openuiChatPromptOptions;定义协调器和面板智能体
createDeepAgent 构建一个唯一职责是路由的协调器:它挑选一份简报所需的专业智能体,并在一条消息中发出它们所有的 task() 调用,使各面板并发运行。每个面板子智能体共享一份预生成的 OpenUI 系统提示词,并且只接收其数据领域对应的工具。
ts
import { createDeepAgent, type SubAgent } from "deepagents";
import { library, promptOptions } from "./library.js";
import { calendarTools, githubTools, posthogTools, stripeTools } from "./tools.js";
// 协调器只负责路由,因此由快速模型处理;面板生成
// 严格的 openui-lang,并保持使用前沿模型。
const COORDINATOR_MODEL = "openai:gpt-5.4-mini";
const PANEL_MODEL = "openai:gpt-5.5";
// 在模块加载时生成一次共享的面板提示词,以便模型前缀
// 保持稳定,利于提供商的提示词缓存。
const PANEL_SYSTEM_PROMPT = library.prompt({
...promptOptions,
preamble:
"Build one panel of a live executive dashboard. Follow the coordinator's " +
"task exactly and stay within the data available from your tools.",
additionalRules: [
...(promptOptions.additionalRules ?? []),
"Use your available data tools before writing the panel.",
"Return the complete openui-lang program and nothing else.",
"Emit the `root` statement on the first line so rendering can start immediately.",
],
});
const subagents: SubAgent[] = [
{
name: "stripe-panel",
model: PANEL_MODEL,
description: "Builds the revenue and payments panel from Stripe data.",
systemPrompt: PANEL_SYSTEM_PROMPT,
tools: stripeTools,
},
// posthog-panel、github-panel 和 calendar-panel 遵循相同的形式。
];
const COORDINATOR_PROMPT = `You orchestrate a live executive dashboard.
1. Delegate immediately. Never write openui-lang yourself.
2. Launch all selected specialists in a SINGLE message, one task call per
panel, so they run concurrently.
3. Give each task a distinct, self-contained description.
4. After the tasks complete, reply with one short plain-text summary.`;
export const dashboard = createDeepAgent({
model: COORDINATOR_MODEL,
systemPrompt: COORDINATOR_PROMPT,
subagents,
});协调器从不编写 openui-lang。每个面板智能体先调用自己的工具,然后返回一个以 root 开头的完整程序,这样其渲染器就可以在模型完成其余语句之前开始绘制。
注册图
将 langgraph.json 指向导出的协调器:
json
{
"node_version": "22",
"graphs": {
"dashboard": "./src/agent.ts:dashboard"
},
"env": "../../.env"
}在前端发现并渲染面板
一条 useStream 连接同时承载协调器和每个面板。面板并不是硬编码的:每次并行的 task() 调用都会以 stream.subagents 快照的形式呈现。对于每个快照,将 useMessages(stream, snapshot) 投影限定在该子智能体范围内,使一个面板只接收它自己的子智能体的消息,然后将它的 OpenUI 程序输入到一个独立的 Renderer 中:
tsx
import { memo } from "react";
import type { SubagentDiscoverySnapshot } from "@langchain/langgraph-sdk/stream";
import { useMessages, useStream } from "@langchain/react";
import { Renderer, type ActionEvent } from "@openuidev/react-lang";
import { library } from "./library";
// 一个面板,限定到一个子智能体。已进行记忆化,因此应用外壳的重新渲染
// 永远不会到达此 Renderer;面板自身的 token 通过 useMessages 到达。
const Panel = memo(function Panel({
stream,
snapshot,
isStreaming,
onAction,
}: {
stream: ReturnType<typeof useStream>;
snapshot: SubagentDiscoverySnapshot;
isStreaming: boolean;
onAction: (event: ActionEvent) => void;
}) {
const messages = useMessages(stream, snapshot);
// 该程序是文本以 `root =` 开头的最后一条 AI 消息。
const program = programFromMessages(messages);
if (program === "") return <PanelSkeleton name={snapshot.name} />;
return (
<Renderer
response={program}
library={library}
isStreaming={isStreaming}
onAction={onAction}
/>
);
});
export function Dashboard() {
const stream = useStream({
assistantId: "dashboard",
apiUrl: import.meta.env.VITE_LANGGRAPH_API_URL ?? "http://localhost:2024",
});
// 从流中发现顶层面板;布局会适应协调器委派了哪些
// 专业智能体。
const panels = [...stream.subagents.values()].filter(
(snapshot) => snapshot.parentId === null,
);
return (
<main>
{panels.map((snapshot) => (
<Panel
key={snapshot.id}
stream={stream}
snapshot={snapshot}
isStreaming={snapshot.status === "running" && stream.isLoading}
onAction={(event) => {
// 处理 continue_conversation 和 open_url 动作。
}}
/>
))}
</main>
);
}由于 SDK 将子智能体的 token 事件保持在根 store 之外,并且每个 Panel 都基于其快照身份进行了记忆化,因此一个面板的 token 永远不会触发另一个面板重新渲染。
最佳实践
- 在模块加载时生成系统提示词: 而不是在 React 组件内部;该提示词有数千字节,应该只计算一次
- 只在新线程上注入系统提示词: 检查
stream.messages.length === 0,并在后续轮次跳过注入,以避免在线程历史中重复提示词 - 使用提升式顺序: 先写
root = Stack([...]);UI 外壳立即出现,各个部分在模型逐一定义时渐进式填充 - 在完整语句处才更新: 避免在每收到一个 token 时就重新渲染 Renderer;仅在完整的语句(
name = ComponentCall(...))到达时才更新 - 在渲染前验证图表数据: 图表组件需要先定义好它们的
Series和标签数组,才能包含到稳定快照中 - 保持 camelCase 变量名: openui-lang 解析器只接受 camelCase 标识符;在系统提示词的
additionalRules中强化这一点 - 在一条消息中委派面板: 当分发给 Deep Agents 专业智能体时,在单条协调器消息中发出所有
task()调用,以便各面板并发流式输出,而不是一个个依次进行 - 将每个面板限定到它的子智能体: 从
stream.subagents发现面板,并将每个快照传给useMessages(stream, snapshot),使一个面板只渲染它自己的子智能体的输出