Skip to content

OpenUI ​

使用 OpenUI 组件库和 openui-lang 生成完整的交互式仪表盘与报表

OpenUI 是一个生成式 UI 库,它允许语言模型以名为 openui-lang 的声明式格式生成完整、交互式的 UI。Agent 不再返回聊天消息,而是返回一个包含卡片、图表、表格、标签页和表单的组件树,Renderer 会将其转换为真实的 React UI。

该集成非常适合报告、仪表盘和数据探索器等数据密集型输出场景,在这些场景中,模型既是数据分析师,又是 UI 设计师。

工作原理 ​

  1. 生成系统提示词: 在启动时调用一次 openuiLibrary.prompt();它会生成完整的 openui-lang 参考规范,模型可据此编写有效的组件树
  2. 在首条消息中注入: 新对话开始时,将系统提示词作为初始系统消息发送
  3. 模型编写 openui-lang: 模型返回诸如 root = Stack([header, kpis, chart]) 之类的程序,而非散文文本
  4. 使用 Renderer 渲染: 将文本传递给 OpenUI 的 Renderer 和组件库;它会解析并渲染组件树

安装 ​

bash
npm install @langchain/react @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang

TIP

OpenUI 需要 React 19+ 和 zustand。前端代码仅支持 React;LangGraph Agent 后端可以用 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 —— 将 Renderer 更新限制在完整的语句边界(name = Expr(…)),而非每个 token
图表空数据崩溃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)的 labels 或 series props 未解析时,会因为在 null 上调用 `.map()` 而崩溃。
 * 在提交稳定快照之前,请验证文本中的每个图表是否都已定义了其所有数据变量。
 */
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">
            {msg.text}
          </div>
        </div>
      );
    }

    if (msg.getType() === "ai") {
      const raw = sanitizeIdentifiers(
        stripCodeFence(msg.text),
      );
      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 为每份报告添加一个“进一步探索(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 协调器可以将任务委派给多个专家 Agent,每个专家 Agent 都可以通过同一个 useStream 连接并发流式传输各自的 OpenUI 面板。OpenUI 并行仪表盘示例 将一份仪表盘需求转化为独立流式传输的 Stripe、PostHog、GitHub 和 Calendar 面板,无需任何自定义图或流解复用代码。

共享同一个 OpenUI 库 ​

在服务端(用于生成面板提示词)和客户端(作为 Renderer 的 prop)使用同一个 library 对象,以便模型获知的组件始终与渲染器能够绘制的组件相匹配:

library.ts
ts
import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui";

export const library = openuiChatLibrary;
export const promptOptions = openuiChatPromptOptions;

定义协调器与面板 Agent ​

createDeepAgent 构建了一个仅负责路由的协调器:它挑选出需求所需的专家,并在单条消息中发出所有专家的 task() 调用,从而使面板能够并发运行。每个面板子 Agent 共享同一个预先生成的 OpenUI 系统提示词,并且仅接收与其数据领域相关的工具。

expandable agent.ts
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";

// 在模块加载时生成一次共享面板提示词,以便模型前缀
// 保持稳定,从而利用提供商的提示词缓存(prompt caching)。
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。每个面板 Agent 调用其工具,然后返回一个以 root 开头的完整程序,以便其渲染器在模型完成其余语句之前就能开始绘制。

注册图 ​

将 langgraph.json 指向导出的协调器:

langgraph.json
json
{
  "node_version": "22",
  "graphs": {
    "dashboard": "./src/agent.ts:dashboard"
  },
  "env": "../../.env"
}

在前端发现并渲染面板 ​

一个 useStream 连接即可承载协调器和所有面板。面板并非硬编码:每次并行的 task() 调用都会呈现为一个 stream.subagents 快照。对于每个快照,划分出一个 useMessages(stream, snapshot) 投影,使面板仅接收其自身子 Agent 的消息,然后将其 OpenUI 程序传入独立的 Renderer:

expandable App.tsx
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";

// 一个面板,作用域限定为一个子 Agent。通过 memo 进行缓存,使应用外壳的重新渲染
// 永远不会波及此 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 将子 Agent 的 token 事件隔离在根 Store 之外,并且每个 Panel 都根据其快照标识进行了 memo 缓存,因此来自一个面板的 token 永远不会触发另一个面板的重新渲染。

最佳实践 ​

  • 在模块加载时生成系统提示词: 不要放在 React 组件内部;提示词有数 KB 大小,应该只计算一次
  • 仅在全新会话线程中注入系统提示词: 检查 stream.messages.length === 0,并在后续轮次中跳过注入,以避免在线程历史中重复提示词
  • 采用提升顺序: 优先编写 root = Stack([...]);UI 外壳将立即显现,随着模型逐步定义每个分区,内容会渐进式填充
  • 基于完整语句进行门控更新: 避免在每个 token 到达时都重新渲染 Renderer;仅在完整语句(name = ComponentCall(...))到达时才更新
  • 在渲染前验证图表数据: 图表组件需要先定义好其 Series 和标签数组,然后才能被纳入稳定快照中
  • 保持 camelCase 变量名: openui-lang 解析器仅接受 camelCase 标识符;在系统提示词的 additionalRules 中强化这一规则
  • 在单条消息中委派所有面板任务: 在向 Deep Agents 专家分发任务时,在单条协调器消息中发出所有 task() 调用,以便面板能并发流式传输,而非逐个进行
  • 将每个面板的作用域限定到对应的子 Agent: 从 stream.subagents 中发现面板,并将每个快照传递给 useMessages(stream, snapshot),使面板仅渲染自身子 Agent 的输出