Renderer 将其转换为真实的 React UI。
此集成非常适合数据丰富的输出,如报告、仪表板和数据浏览器,其中模型既是数据分析师又是 UI 设计师。
工作原理
- **生成系统提示词:**在启动时调用一次
openuiLibrary.prompt();它会生成一个完整的 openui-lang 参考文档,模型使用它来编写有效的组件树 - **在首条消息中注入:**当新对话开始时,将系统提示词作为开头的系统消息发送
- **模型编写 openui-lang:**模型以如
root = Stack([header, kpis, chart])的程序形式响应,而非散文 - **使用
Renderer渲染:**将文本传递给 OpenUI 的Renderer和组件库;它解析并渲染组件树
安装
npm install @langchain/react @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang
OpenUI 需要 React 19+ 和
zustand。前端代码仅支持 React;LangGraph 智能体后端可以用 TypeScript 或 Python 编写。导入组件样式
在你的 CSS 入口点或直接在根组件中导入 OpenUI 的打包样式:@import "@openuidev/react-ui/components.css";
@import "@openuidev/react-ui/styles/index.css";
生成系统提示词
OpenUI 提供了一个openuiLibrary.prompt() 函数,用于生成完整的 openui-lang 参考文档,包含所有组件签名、语法规则、流式输出技巧和示例。在模块加载时调用一次:
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
// 生成完整的 openui-lang 系统提示词。在启动时调用一次,
// 不要在组件内部调用,以避免每次渲染都重新计算。
const SYSTEM_PROMPT = openuiLibrary.prompt({
...openuiPromptOptions,
preamble:
"你是一个报告生成器。当被要求生成报告时,使用 openui-lang 生成详细的、" +
"数据丰富的报告:执行摘要、KPI 卡片、图表、" +
"表格和多个部分。你的整个响应必须是原始 openui-lang " +
"——没有代码围栏、没有 markdown、没有散文。",
});
preamble 覆盖默认角色。添加 additionalRules 注入任务特定的约束:
const SYSTEM_PROMPT = openuiLibrary.prompt({
...openuiPromptOptions,
preamble: "你是一个报告生成器...",
additionalRules: [
...(openuiPromptOptions.additionalRules ?? []),
"始终在报告末尾使用 " +
"Button({ type: 'continue_conversation' }, 'secondary') 在 " +
"Card([CardHeader('深入探索'), Buttons([...])], 'sunk') 中添加 3-4 个后续查询按钮。",
],
});
通过 useStream 注入系统提示词
在每个新线程的第一条消息中发送系统提示词。检查stream.messages.length === 0 来检测新线程并前置一条 system 消息:
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 ?? "/api/langgraph",
assistantId: "my_agent",
reconnectOnMount: true,
fetchStateHistory: true,
});
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:
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 = typeof msg.content === "string" ? msg.content : "";
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("2025 年 AI 发展现状", "综合分析")
execSummary = MarkDownRenderer("## 执行摘要\n\nAI 市场已达...")
kpi1 = Card([CardHeader("$826B", "全球市场"), TextContent("同比增长 42%", "small")], "sunk")
kpi2 = Card([CardHeader("78%", "采用率"), TextContent("财富 500 强", "small")], "sunk")
kpis = Stack([kpi1, kpi2], "row", "m", "stretch", "start", true)
col1 = Col("细分领域", "string")
col2 = Col("收入 ($B)", "number")
tbl = Table([col1, col2], [["生成式 AI", 286], ["ML 基础设施", 198]])
s1 = Series("收入", [286, 198, 147])
ch1 = BarChart(["生成式 AI", "ML 基础设施", "视觉"], [s1])
marketSection = Card([CardHeader("市场分析"), tbl, ch1])
root 行先写出,使页面结构立即出现,每个部分随着模型的定义逐步填充。
渐进式渲染工具
将useStream 直接连接到 Renderer 会导致每个流式 Token 都触发重新渲染,产生数百次无操作的重新解析。这会导致图表组件在数据尚未到达时崩溃。以下工具解决了这些问题:
| 问题 | 解决方案 |
|---|---|
| 不完整的字符串字面量 | truncateAtOpenString / closeOrTruncateOpenString —— 在解析前删除或关闭不完整的字符串 |
| Token 中间的抖动 | useStableText —— 在完整语句边界(name = Expr(…))而非每个 Token 上更新 Renderer |
| 图表空数据崩溃 | chartDataRefsResolved —— 在包含图表之前验证其 Series 和标签数组已定义 |
尚无 root / 回退 | buildProgressiveRoot —— 当模型尚未写出 root 时,从顶层变量合成 root = Stack([…]) |
| Snake_case 标识符 | sanitizeIdentifiers —— 解析器只接受 camelCase;转换模型发出的 snake_case 名称 |
stable 传递给 <Renderer>:
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",
]);
/**
* 图表组件(recharts)在标签或 series props 未解析时会因
* `.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 (
<div key={msg.id ?? i} className="flex justify-end">
<div className="user-bubble">
{typeof msg.content === "string" ? msg.content : ""}
</div>
</div>
);
}
if (msg.getType() === "ai") {
const raw = sanitizeIdentifiers(
stripCodeFence(typeof msg.content === "string" ? msg.content : ""),
);
if (!raw) return null;
return (
<div key={msg.id ?? i}>
<AIMessageView
raw={raw}
isStreaming={isLoading && i === lastAiIdx}
onSubmit={onSubmit}
/>
</div>
);
}
return null;
});
}
后续查询
OpenUI 的Button 组件支持 continue_conversation 操作类型。当用户点击后续按钮时,Renderer 触发 onAction,上面的 AIMessageView 将按钮标签作为下一条用户消息提交,与在输入框中输入完全相同的代码路径。
通过系统提示词中的 additionalRules 为每个报告添加”深入探索”部分:
followUp1 = Button("比较 2024 与 2025 年 AI 领军者", { type: "continue_conversation" }, "secondary")
followUp2 = Button("全球 AI 投资细分", { type: "continue_conversation" }, "secondary")
followUpBtns = Buttons([followUp1, followUp2], "row")
followUpCard = Card([CardHeader("深入探索"), followUpBtns], "sunk")
root = Stack([..., followUpCard])
最佳实践
- **在模块加载时生成系统提示词:**不要在 React 组件内部生成;提示词有几千字节,应只计算一次
- **仅在新线程中注入系统提示词:**检查
stream.messages.length === 0,在后续轮次中跳过注入,避免在线程历史中重复提示词 - **使用提升顺序:**先写
root = Stack([...]);UI 外壳立即出现,各部分随着模型定义每个部分而逐步填充 - **在完整语句上更新:**避免在每个 Token 上重新渲染 Renderer;仅在完整语句(
name = ComponentCall(...))到达时更新 - **渲染前验证图表数据:**图表组件需要其
Series和标签数组在包含到稳定快照之前已定义 - **保持 camelCase 变量名:**openui-lang 解析器只接受 camelCase 标识符;在系统提示词的
additionalRules中强化这一点
将这些文档连接到 Claude、VSCode 等工具,通过 MCP 获取实时答案。

