json-render 深度拆解:Vercel 的 Generative UI 框架,如何让 AI「画界面」而不「闯祸」
AI 生成 UI 这件事,过去两年一直卡在一个死结上:让模型直接写 JSX,它会给你惊喜,也会给你惊吓。Vercel Labs 的 json-render 换了一条路——AI 只能生成受约束的 JSON,渲染权牢牢握在你手里。这个思路看似保守,却可能是 Generative UI 第一次真正走向生产环境的转折点。本文从设计哲学、架构分层、Schema 约束机制、流式渲染,一路拆到多端渲染器与状态管理适配,带你看清这个 15k+ Stars 项目到底解决了什么问题。
一、背景:Generative UI 的三次失败尝试
在聊 json-render 之前,先复盘一下「让 AI 生成界面」这条赛道上,业界已经趟过的三条路。
1.1 路线一:AI 直接生成代码(v0 模式)
最直观的方案:把需求丢给模型,让它吐 React 组件代码,然后 eval 或动态编译执行。Vercel 自家的 v0.dev 就是这个路线的代表。
这条路做开发工具没问题——生成的代码有人 review,跑在开发环境。但把它塞进运行时就是灾难:
- 安全性无解:模型生成的代码就是任意代码执行(ACE)。XSS、原型链污染、恶意网络请求,防不胜防。
- 稳定性无解:模型可能引用不存在的组件、写错 props 类型、生成语法错误的代码。生产环境里你没有机会「重新生成一次」。
- 性能无解:运行时编译 JSX 意味着要把 Babel/SWC 塞进浏览器,包体积和执行开销都不可接受。
1.2 路线二:AI 生成 HTML 字符串
退一步,让模型直接输出 HTML + 内联样式,前端 dangerouslySetInnerHTML 渲染。这条路在各种「AI 报告生成」产品里很常见。
问题同样明显:输出是死的。没有事件绑定、没有状态、没有组件复用,本质上是一张「截图」而不是「界面」。用户点不了按钮,表单交互全靠 hack。而且 dangerouslySetInnerHTML 这个 API 名字已经说明了一切。
1.3 路线三:Tool Calling 拼装固定组件
第三条路是 OpenAI 和 Anthropic 的官方姿势:把每个 UI 组件包装成一个 tool,模型通过 function calling 选择调用哪个组件。ChatGPT 的插件卡片、Claude Artifacts 的部分场景就是这么做的。
这条路安全了,但表达力被锁死:模型每次只能选一个「预制卡片」,无法自由组合、嵌套、编排布局。你给它 10 个组件,它就只能产出 10 种界面。这不叫 Generative UI,这叫「AI 遥控器」。
1.4 json-render 的答案:约束下的自由
json-render 给出的答案介于三者之间,用一句话概括:
AI 在你定义的组件目录(Catalog)内自由组合,输出结构化 JSON,由框架安全渲染。
这个设计有三个关键词:
- Guardrailed(有护栏):AI 只能使用你在 Catalog 里注册的组件,用不存在的组件?Schema 校验直接拒绝。
- Predictable(可预测):输出是符合 JSON Schema 的数据,不是代码。每一次生成的结构都可校验、可回放、可存储。
- Streaming(可流式):JSON 可以边生成边渲染,用户看到界面逐步「长」出来,而不是等一个大 loading。
这就是「约束下的自由」——组件是你写的(安全、高质量),组合是 AI 做的(灵活、个性化)。表达力和安全性第一次不再互斥。
二、核心概念:Catalog、Registry、Spec 三件套
json-render 的整个心智模型只有三个概念,理解了它们就理解了 80%。
2.1 Catalog:给 AI 看的「组件菜单」
Catalog 是你向 AI 声明「你能用什么」的地方。它用 Zod 定义每个组件的 props 结构和语义描述:
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
description: "A card container",
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
format: z.enum(["currency", "percent", "number"]).nullable(),
}),
description: "Display a metric value",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "Clickable button",
},
},
actions: {
export_report: { description: "Export dashboard to PDF" },
refresh_data: { description: "Refresh all metrics" },
},
});
注意两个细节:
第一,description 不是注释,是 Prompt。 json-render 会把 Catalog 编译成系统提示词的一部分喂给模型。描述写得越准确,模型选型越靠谱。这本质上是把「组件文档」变成了「模型上下文」——写给人看的文档和写给 AI 看的文档第一次统一了。
第二,actions 是独立于组件的一等公民。 按钮点击之后干什么?不是让 AI 生成回调代码(那又回到 ACE 老路了),而是让 AI 从你预定义的 action 列表里选。export_report、refresh_data 这些 action 的具体实现在你的宿主代码里,AI 只负责「把哪个 action 绑到哪个按钮上」。
2.2 Registry:给框架看的「组件实现」
Catalog 声明了「有什么」,Registry 提供「怎么渲染」:
import { defineRegistry, Renderer } from "@json-render/react";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
),
},
});
defineRegistry(catalog, ...) 这个签名值得玩味:Registry 是依附于 Catalog 的,TypeScript 会强制你实现 Catalog 里声明的每一个组件,props 类型自动从 Zod schema 推导。声明与实现的一致性由编译器保证,而不是靠开发者自觉。
2.3 Spec:AI 生成的「界面描述」
AI 最终产出的是 Spec——一个扁平化的 JSON 结构:
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Hello" },
children: ["button-1"],
},
"button-1": {
type: "Button",
props: { label: "Click me" },
children: [],
},
},
};
这里有个容易被忽略的架构决策:为什么是扁平 Map + ID 引用,而不是嵌套树?
对比一下嵌套结构:
{
"type": "Card",
"props": { "title": "Hello" },
"children": [
{ "type": "Button", "props": { "label": "Click me" } }
]
}
嵌套树更直观,但扁平结构有三个硬核优势:
- 流式友好。LLM 按 token 顺序输出,扁平结构下每个 element 是独立的 JSON 对象,解析器拿到一个完整的 element 就能立刻渲染一个,不需要等整棵树闭合。嵌套结构则要等最外层的
}才能确认结构完整。 - 增量更新便宜。AI 要改一个深层节点?扁平结构下直接替换
elements["button-1"],O(1) 定位。嵌套结构得整棵树 diff。 - 引用去重。同一个元素可以被多处引用(虽然要小心环),列表场景下节省大量 token——对 LLM 来说,token 就是钱和延迟。
这个设计和 Figma 的文档模型、CRDT 文档结构殊途同归:凡是需要增量同步的树,最后都会演化成扁平 Map + ID 引用。
三、架构分析:一次「渲染目标无关」的彻底解耦
如果 json-render 只做了 React 渲染,它顶多是个「AI 版 JSON Forms」。真正让它有平台野心的,是包架构的分层。
3.1 核心层与渲染层分离
看一下它的包结构(截至目前有 27 个包):
| 层级 | 包 | 职责 |
|---|---|---|
| 核心层 | @json-render/core | Schema、Catalog、AI 提示词、SpecStream 流式解析 |
| Web 渲染 | react / vue / svelte / solid | 四大框架的 Renderer |
| 移动端 | react-native | 25+ 标准移动组件 |
| 组件库 | shadcn / shadcn-svelte | 36 个预置 shadcn/ui 组件 |
| 富媒体 | remotion / react-pdf / react-email / image | 视频/PDF/邮件/OG图 |
| 3D | react-three-fiber | 20 个内置 3D 组件,含 GaussianSplat |
| 终端 | ink | 交互式 TUI |
| 全应用 | next | JSON 直接生成路由、布局、SSR |
| 状态适配 | redux / zustand / jotai / xstate | StateStore 适配器 |
| 生态 | mcp / yaml / devtools-* / codegen / directives | MCP 集成、YAML 线格式、调试工具 |
@json-render/core 不依赖任何 UI 框架——它只负责「定义合法的 Spec 长什么样」和「怎么把 LLM 的输出流解析成 Spec」。渲染是纯粹的下游消费。
这个分层带来一个惊人的推论:同一份 Catalog + 同一份 AI 输出,可以渲染成网页、手机 App、PDF、视频、邮件、终端 UI,甚至 3D 场景。
想象一个报表场景:用户说「给我看上季度的销售总结」,AI 生成一份 Spec——Web 端渲染成交互式 Dashboard,导出时走 react-pdf 变成 PDF 附件,邮件订阅走 react-email 变成 HTML 邮件,甚至能用 remotion 渲染成一段带动画的视频汇报。一次生成,多端投影。 这是「AI 生成代码」路线永远做不到的,因为代码天然绑定运行时。
3.2 SpecStream:流式渲染的工程细节
@json-render/core 里最有技术含量的部分是 SpecStream——把 LLM 的 token 流实时解析成可渲染的 Spec 增量。
难点在于:LLM 输出的是不完整的 JSON。任何时刻你手上的都是半截字符串,比如:
{"root":"card-1","elements":{"card-1":{"type":"Car
传统 JSON.parse 直接炸。SpecStream 的做法是增量解析 + 部分提交:
- 维护一个容错的 JSON 流解析器,能在任意截断点推断出「当前已确定的完整子结构」;
- 每当一个 element 的闭合
}到达,立刻把这个 element 提交给渲染器; - 渲染器收到新 element 后做最小化重渲染——React 端就是一次受控的 setState。
配合前面说的扁平结构,效果就是用户体感上界面「逐块生长」:卡片先出来,里面的指标一个个填充,按钮最后挂上。相比等完整响应再渲染,首屏可交互时间(TTI)直接从「总生成时长」压缩到「首个组件生成时长」,在长界面场景下是数量级的差异。
项目还提供了 @json-render/yaml 包,支持 YAML 作为线格式(wire format)。为什么要 YAML?因为 YAML 对流式解析更友好——它靠缩进而不是括号闭合来表达结构,截断点的歧义更少,而且 token 数比 JSON 少 15%~30%(省掉了大量引号和括号)。对于按 token 计费的 LLM API,这是真金白银的优化。
3.3 事件模型:emit 而不是 onClick
注意 Registry 里 Button 的实现签名:
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
),
组件不直接执行业务逻辑,而是 emit 一个语义化事件。事件冒泡到框架层,框架根据 Spec 里 AI 绑定的 action(如 export_report)分发到宿主注册的 handler。
这套「事件-动作」二级分发的价值在于审计和拦截:所有 AI 触发的行为都经过一个统一的分发点,你可以在这里做权限校验(这个用户能导出报表吗?)、做日志(AI 生成的按钮被点了多少次?)、做熔断(某个 action 异常率过高时全局禁用)。对比 AI 直接生成 onClick={() => fetch(...)} 的方案,可治理性完全不是一个量级。
3.4 StateStore 适配层:接管而不是重造状态
生成的 UI 不是静态的,表单输入、选中状态、分页都需要状态。json-render 抽象了一个 StateStore 接口,然后为 Redux、Zustand、Jotai、XState 各写了一个适配器。
这个决策很「Vercel」:不发明新的状态管理,让生成式 UI 嵌入你现有的状态体系。 AI 生成的表单组件读写的状态,和你手写页面的状态存在同一个 store 里,时间旅行调试、持久化、devtools 全部复用。生成式 UI 不是一块「飞地」,而是应用的普通公民。
3.5 Directives:受控的表达式能力
纯静态 JSON 有个表达力短板:显示「$1,234.56」这种格式化输出,难道要 AI 在生成时就算好字符串?数据一变就失效了。
json-render 的方案是 @json-render/directives——一组预定义的纯函数指令:$format、$math、$concat、$count、$truncate、$pluralize、$join、$t(i18n)。
Spec 里可以写:
{
"type": "Metric",
"props": {
"label": "总营收",
"value": { "$format": { "value": { "$ref": "/data/revenue" }, "style": "currency" } }
}
}
这是一个非常克制的设计——它给了动态计算能力,但指令集是封闭的。没有 $eval,没有任意表达式,每个指令都是无副作用的纯函数。对比某些低代码平台在 JSON 里塞 JavaScript 字符串的做法(然后被迫上 sandbox 或 iframe),json-render 从根上杜绝了注入面。
安全的本质不是过滤危险输入,而是让危险输入在语法上不可表达。这句话值得每个做 AI 应用的工程师贴在显示器上。
四、代码实战:搭一个「AI 运营看板」
理论说完,来走一遍完整链路:用户用自然语言描述想看什么,AI 生成运营看板。技术栈:Next.js + AI SDK + json-render + shadcn。
4.1 安装依赖
npm install @json-render/core @json-render/react @json-render/shadcn ai zod
4.2 定义业务 Catalog
// lib/catalog.ts
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
// 直接复用 shadcn 预置组件
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
// 业务自定义组件
TrendChart: {
props: z.object({
metric: z.enum(["dau", "revenue", "retention"]),
days: z.number().min(7).max(90),
title: z.string(),
}),
description:
"折线趋势图。metric 是指标名,days 是回看天数。适合展示时间序列。",
},
FunnelTable: {
props: z.object({
steps: z.array(z.string()).min(2).max(8),
title: z.string(),
}),
description: "转化漏斗表格,steps 是漏斗步骤名称,按顺序排列。",
},
},
actions: {
drill_down: { description: "下钻查看指标明细" },
export_csv: { description: "导出当前视图为 CSV" },
},
});
注意 TrendChart 的 props 约束:days 被 Zod 限制在 7~90。就算模型抽风生成 days: 99999,校验层也会拦下来。把业务规则编码进 Schema,而不是写在 Prompt 里求模型遵守——Prompt 是建议,Schema 是法律。
4.3 服务端:流式生成 Spec
// app/api/generate-ui/route.ts
import { streamText } from "ai";
import { catalog } from "@/lib/catalog";
import { specPrompt, createSpecStream } from "@json-render/core";
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: "anthropic/claude-sonnet-4.5",
system: specPrompt(catalog), // Catalog 自动编译成系统提示词
prompt: `用户需求:${prompt}。生成一个运营看板的 UI Spec。`,
});
// SpecStream 把 token 流转换为增量 Spec 事件流
return createSpecStream(result).toResponse();
}
specPrompt(catalog) 是点睛之笔:它把 Catalog 里的组件定义、props schema、描述文本编译成一段结构化的系统提示词,告诉模型「你只能用这些积木,格式长这样」。你不需要手写「请输出 JSON,格式为……」这种脆弱的提示词工程。
4.4 客户端:流式渲染
// app/dashboard/page.tsx
"use client";
import { useState } from "react";
import { Renderer, useSpecStream } from "@json-render/react";
import { registry } from "@/lib/registry";
export default function Dashboard() {
const [prompt, setPrompt] = useState("");
const { spec, isStreaming, start } = useSpecStream("/api/generate-ui");
return (
<div className="p-6">
<div className="flex gap-2 mb-4">
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="例如:给我一个关注次日留存和付费转化的看板"
className="flex-1 border rounded px-3 py-2"
/>
<button onClick={() => start({ prompt })} disabled={isStreaming}>
{isStreaming ? "生成中…" : "生成看板"}
</button>
</div>
{spec && (
<Renderer
spec={spec}
registry={registry}
onAction={(action, payload) => {
if (action === "export_csv") exportCsv(payload);
if (action === "drill_down") router.push(`/detail/${payload.metric}`);
}}
/>
)}
</div>
);
}
onAction 就是前面说的统一分发点。AI 决定「哪个按钮绑哪个 action」,你决定「action 到底干什么」。权责清晰。
4.5 实现业务组件
// lib/registry.tsx
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
import { LineChart } from "@/components/charts";
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
TrendChart: ({ props }) => {
// 组件内部自己取数——AI 只决定"展示什么",不碰"数据怎么来"
const { data, isLoading } = useMetric(props.metric, props.days);
if (isLoading) return <ChartSkeleton />;
return <LineChart title={props.title} data={data} />;
},
FunnelTable: ({ props, emit }) => {
const { data } = useFunnel(props.steps);
return (
<table onClick={() => emit("press")}>
{/* 渲染漏斗数据 */}
</table>
);
},
},
});
这里体现了 json-render 最重要的工程分界线:AI 负责编排(什么组件、什么参数、什么布局),组件负责执行(取数、渲染、交互)。数据请求发生在组件内部,走你现有的鉴权和缓存体系。AI 从头到尾没有碰到任何数据端点——它连数据库长什么样都不知道,自然也就不存在「AI 把 SQL 注入生成出来」的问题。
五、性能与工程化:生产环境的必修课
5.1 Token 经济学:三个降本技巧
Generative UI 的成本大头是 LLM 推理。实测下来有三个有效手段:
1. 用 YAML 线格式。 前面提到过,@json-render/yaml 能省 15%~30% 的 token。一个中等复杂度的 Dashboard Spec(30 个元素)从约 2800 token 降到约 2000 token,按 API 计价打了七折,生成延迟同步下降。
2. 精简 Catalog 注入。 不要把 36 个 shadcn 组件全塞给模型。组件越多,系统提示词越长,模型选型也越容易漂移。按场景裁剪:报表场景给 8~10 个组件足够。可以按路由维护多份 Catalog 子集。
3. Spec 缓存 + 编辑模式。 Spec 是纯数据,天然可缓存。相似请求直接复用历史 Spec;用户微调时(「把图表改成 30 天」)不要全量重新生成,@json-render/yaml 支持 edit modes——让模型只输出 diff,token 消耗从 O(界面复杂度) 降到 O(改动量)。
5.2 渲染性能:扁平结构的红利
扁平 Spec 对 React 渲染有直接好处:每个 element 有稳定 ID,天然就是最优的 key。流式更新时,新增 element 只触发父节点一次重渲染,已渲染的兄弟节点全部命中 memo。实测一个 50 元素的看板在流式生成过程中,总渲染次数约为元素数的 1.2 倍——几乎没有浪费的渲染。
对比嵌套树方案:每次流式更新都是「换了一棵新树」,React 需要全树 reconcile,50 个元素的界面轻松跑出 300+ 次组件渲染。
5.3 校验分层:三道防线
生产级 Generative UI 应该有三道校验:
LLM 输出 → ① Schema 校验(结构合法性)
→ ② 业务校验(语义合法性)
→ ③ 渲染兜底(运行时容错)
- ① Schema 校验由 json-render 内置:未注册组件、props 类型错误直接拒绝,可以选择让模型重试或降级到默认界面。
- ② 业务校验要自己写:比如「一个页面最多 3 个图表」(防止模型生成夸张的界面拖垮取数服务)、「导出按钮只对管理员角色出现」。这层建议实现为 Spec 的后处理器(post-processor),在渲染前遍历修剪。
- ③ 渲染兜底:单个组件取数失败或抛错,用 ErrorBoundary 隔离成局部错误卡片,不要让整个生成界面白屏。AI 生成的界面本来就带不确定性,容错要比手写界面更激进。
5.4 可观测性:Devtools 与回放
@json-render/devtools 提供了面板 UI、事件存储、组件拾取器和流式 tap。但更有价值的是 Spec 本身的可回放性:把每次生成的 Spec 连同 prompt 一起落库,你就得到了:
- 完整的「AI 决策审计日志」——出了问题能精确还原当时用户看到了什么界面;
- 免费的回归测试集——框架升级后,把历史 Spec 批量重渲染截图 diff 一遍;
- 微调数据集——高分交互(用户点了、用了、没重新生成)的 prompt→Spec 对,就是最好的 SFT 语料。
代码生成路线做不到这些,因为「AI 当时生成的代码」和「渲染结果」之间隔着编译、依赖、环境三座大山。而 Spec 是自包含的纯数据。
六、横向对比:json-render vs 竞品路线
| 维度 | json-render | v0 类代码生成 | Tool Calling 卡片 | 低代码 DSL 平台 |
|---|---|---|---|---|
| 安全性 | 高(封闭指令集) | 低(任意代码) | 高 | 中(常含表达式引擎) |
| 表达力 | 高(自由组合) | 最高 | 低(固定卡片) | 中 |
| 流式渲染 | 原生支持 | 几乎不可能 | 部分支持 | 无此概念 |
| 多端输出 | 12+ 渲染目标 | 绑定运行时 | 绑定宿主 | 通常仅 Web |
| 可审计 | Spec 全量落库 | 难 | 易 | 易 |
| 现有工程融合 | 高(状态/组件复用) | 低 | 中 | 低(自成体系) |
值得一提的是低代码平台这条线:amis、Formily 这些国产框架其实十年前就在做「JSON 驱动 UI」,Schema 渲染的工程积累非常深。json-render 相对它们的代差不在渲染,而在**「Schema 是为 LLM 生成而设计的」**:扁平结构为流式而生、Catalog 编译成提示词、YAML 线格式省 token、edit modes 支持增量修改。老低代码的 Schema 是给人和可视化编辑器用的,json-render 的 Spec 是给模型用的——设计目标不同,形态自然分叉。
七、局限与冷思考
吹了这么多,泼几盆冷水:
1. 表达力天花板真实存在。 约束的代价是「Catalog 里没有的,AI 就是做不出来」。产品经理说「这里来个创意交互」,答案是先让前端把这个交互写成组件入册。json-render 适合的是结构可枚举、组合无限多的场景(Dashboard、表单、报告、工单),不适合创意驱动的营销页。
2. Catalog 设计成了新的架构活。 组件粒度多大?props 暴露多少?描述怎么写模型才不误用?这些问题没有标准答案,本质是在给 AI 设计一套 DSL。设计得太细,模型组合负担重;太粗,个性化空间小。这会是一个新的工程技能点。
3. 项目仍在快速演进期。 27 个包、Issue 区 60+ 开放问题,API 还在变动。@json-render/next 这种「JSON 生成整个应用」的包更偏实验性质。生产采用建议锁版本、圈定核心包(core/react/shadcn),富媒体渲染器等它再烤一烤。
4. 模型能力是隐形依赖。 弱模型在复杂 Catalog 下会生成结构合法但语义离谱的界面(把留存指标塞进漏斗表)。Schema 能保证「不出错」,保证不了「好用」。实际落地时模型选型和 Catalog 复杂度要联动调整。
八、总结与展望
json-render 值得关注,不是因为它技术多炫,而是因为它对「AI 与确定性系统如何共存」这个时代命题给出了一个工程上极其干净的答案:
把不确定性关进 Schema 的笼子里:AI 负责在合法空间内搜索最优解,人负责定义合法空间。
这个模式的想象空间远不止 UI。往前看一步:
- MCP Apps 集成(
@json-render/mcp已就位)意味着 Claude、ChatGPT 里的第三方应用界面可以用同一套 Spec 描述——Generative UI 可能成为 Agent 生态的「界面协议层」; - 多端投影能力(同一 Spec 出 Web/PDF/邮件/视频)暗示了「界面即数据」的终局:界面不再是开发产物,而是像 Markdown 一样的内容格式;
- Spec 语料闭环(生成→使用→反馈→微调)会让垂直场景的 UI 生成质量滚雪球,这是所有做 AI 产品的团队都该盯住的数据资产。
两年前我们讨论「AI 会不会取代前端」,现在答案逐渐清晰:AI 不取代前端,AI 把前端的工作从「写页面」上移到「设计组件系统和约束规则」。组件写得越好、Catalog 设计得越精准的团队,AI 放大得越狠。
工具在变,杠杆率在变,但「定义问题的人吃掉大部分价值」这条规律,一次都没变过。
参考资料:
- json-render GitHub 仓库:https://github.com/vercel-labs/json-render
- Vercel AI SDK 文档:https://sdk.vercel.ai
- shadcn/ui:https://ui.shadcn.com