从 boltArtifact 到自定义渲染协议:打造你自己的 AI 组件系统
本文将介绍如何借鉴 boltArtifact 的设计思路,为你的业务实现一套自定义的 AI 渲染协议,支持流式解析和组件化渲染。
一、为什么需要自定义渲染协议?
在 AI 应用开发中,我们经常遇到这样的场景:
- 电商场景:AI 推荐商品时,希望直接渲染商品卡片而非纯文本
- 数据分析:AI 生成报告时,希望内嵌可交互的图表
- 表单生成:AI 根据用户需求动态生成表单组件
- 工作流编排:AI 输出的流程图需要可视化展示
boltArtifact 给我们的启示是:通过自定义 XML 标签协议,可以在 AI 流式输出中嵌入结构化数据,前端实时解析并渲染为对应组件。
二、协议设计框架
2.1 通用协议结构
<{namespace}{ComponentType} {...attributes}>
<{namespace}{ActionType} {...actionAttributes}>
{content}
</{namespace}{ActionType}>
</{namespace}{ComponentType}>
设计要点:
namespace:命名空间前缀,避免与 HTML 标签冲突(如bolt、ai、biz)ComponentType:组件容器类型ActionType:具体操作或子组件类型attributes:元数据,用于标识和配置content:实际内容数据
2.2 命名规范建议
| 元素 | 规范 | 示例 |
|---|---|---|
| 命名空间 | 小写,2-4 字母 | ai、biz、app |
| 组件标签 | PascalCase | AiCard、BizChart |
| 属性名 | camelCase | dataType、chartId |
| ID 值 | kebab-case | product-card-1 |
三、实战案例:电商商品推荐系统
3.1 定义协议规范
<!-- 商品卡片 -->
<aiCard id="product-rec-1" type="product">
<aiData format="json">
{"id": "SKU001", "name": "iPhone 15", "price": 5999, "image": "..."}
</aiData>
</aiCard>
<!-- 商品列表 -->
<aiCard id="product-list-1" type="productList">
<aiData format="json">
[{"id": "SKU001", ...}, {"id": "SKU002", ...}]
</aiData>
</aiCard>
<!-- 比价表格 -->
<aiCard id="compare-table-1" type="comparison">
<aiData format="json">
{"products": [...], "dimensions": ["价格", "屏幕", "电池"]}
</aiData>
</aiCard>
3.2 TypeScript 类型定义
// types/ai-protocol.ts
/** 支持的卡片类型 */
export type CardType =
| 'product' // 单个商品
| 'productList' // 商品列表
| 'comparison' // 对比表格
| 'chart' // 图表
| 'form'; // 表单
/** 数据格式 */
export type DataFormat = 'json' | 'markdown' | 'html';
/** 卡片容器数据 */
export interface AiCardData {
id: string;
type: CardType;
}
/** 数据内容 */
export interface AiDataAction {
format: DataFormat;
content: string;
}
/** 完整的卡片结构 */
export interface AiCard extends AiCardData {
data: AiDataAction;
}
/** 解析回调 */
export interface ParserCallbacks {
onCardOpen?: (card: AiCardData & { messageId: string }) => void;
onCardClose?: (card: AiCard & { messageId: string }) => void;
onDataStart?: (data: { cardId: string; format: DataFormat }) => void;
onDataChunk?: (data: { cardId: string; chunk: string }) => void;
onDataEnd?: (data: { cardId: string; content: string }) => void;
}
3.3 流式解析器实现
// lib/ai-message-parser.ts
const CARD_TAG_OPEN = '<aiCard';
const CARD_TAG_CLOSE = '</aiCard>';
const DATA_TAG_OPEN = '<aiData';
const DATA_TAG_CLOSE = '</aiData>';
interface ParserState {
position: number;
insideCard: boolean;
insideData: boolean;
currentCard?: AiCardData;
currentData: { format: DataFormat; content: string };
}
export class AiMessageParser {
private messages = new Map<string, ParserState>();
private callbacks: ParserCallbacks;
constructor(callbacks: ParserCallbacks = {}) {
this.callbacks = callbacks;
}
/**
* 解析流式输入
* @param messageId 消息唯一标识
* @param input 当前完整的消息文本(累积)
* @returns 去除协议标签后的纯文本
*/
parse(messageId: string, input: string): string {
let state = this.messages.get(messageId);
if (!state) {
state = {
position: 0,
insideCard: false,
insideData: false,
currentData: { format: 'json', content: '' },
};
this.messages.set(messageId, state);
}
let output = '';
let i = state.position;
while (i < input.length) {
if (state.insideCard) {
if (state.insideData) {
// 在 <aiData> 内部,收集内容直到闭合标签
const closeIndex = input.indexOf(DATA_TAG_CLOSE, i);
if (closeIndex !== -1) {
// 找到闭合标签,提取内容
const chunk = input.slice(i, closeIndex);
state.currentData.content += chunk;
this.callbacks.onDataEnd?.({
cardId: state.currentCard!.id,
content: state.currentData.content.trim(),
});
state.insideData = false;
i = closeIndex + DATA_TAG_CLOSE.length;
} else {
// 未找到闭合标签,缓存当前内容,等待更多数据
const chunk = input.slice(i);
state.currentData.content += chunk;
this.callbacks.onDataChunk?.({
cardId: state.currentCard!.id,
chunk,
});
break;
}
} else {
// 在 <aiCard> 内部,寻找 <aiData> 或 </aiCard>
const dataOpenIndex = input.indexOf(DATA_TAG_OPEN, i);
const cardCloseIndex = input.indexOf(CARD_TAG_CLOSE, i);
if (
dataOpenIndex !== -1 &&
(cardCloseIndex === -1 || dataOpenIndex < cardCloseIndex)
) {
// 找到 <aiData> 开始标签
const tagEndIndex = input.indexOf('>', dataOpenIndex);
if (tagEndIndex !== -1) {
const tag = input.slice(dataOpenIndex, tagEndIndex + 1);
const format =
(this.extractAttribute(tag, 'format') as DataFormat) || 'json';
state.insideData = true;
state.currentData = { format, content: '' };
this.callbacks.onDataStart?.({
cardId: state.currentCard!.id,
format,
});
i = tagEndIndex + 1;
} else {
break; // 标签不完整,等待更多数据
}
} else if (cardCloseIndex !== -1) {
// 找到 </aiCard> 闭合标签
const card: AiCard = {
...state.currentCard!,
data: state.currentData,
};
this.callbacks.onCardClose?.({ messageId, ...card });
// 插入占位元素,供前端渲染
output += this.createCardPlaceholder(
messageId,
state.currentCard!.id
);
state.insideCard = false;
state.currentCard = undefined;
state.currentData = { format: 'json', content: '' };
i = cardCloseIndex + CARD_TAG_CLOSE.length;
} else {
break; // 等待更多数据
}
}
} else {
// 在普通文本中,寻找 <aiCard> 开始标签
if (input.slice(i).startsWith(CARD_TAG_OPEN)) {
const tagEndIndex = input.indexOf('>', i);
if (tagEndIndex !== -1) {
const tag = input.slice(i, tagEndIndex + 1);
const id = this.extractAttribute(tag, 'id') || `card-${Date.now()}`;
const type =
(this.extractAttribute(tag, 'type') as CardType) || 'product';
state.insideCard = true;
state.currentCard = { id, type };
this.callbacks.onCardOpen?.({ messageId, id, type });
i = tagEndIndex + 1;
} else {
break; // 标签不完整,等待更多数据
}
} else {
// 普通字符,直接输出
output += input[i];
i++;
}
}
}
state.position = i;
return output;
}
/** 重置解析器状态 */
reset() {
this.messages.clear();
}
/** 提取标签属性值 */
private extractAttribute(tag: string, name: string): string | undefined {
const match = tag.match(new RegExp(`${name}=["']([^"']*)["']`, 'i'));
return match?.[1];
}
/** 创建占位 HTML 元素 */
private createCardPlaceholder(messageId: string, cardId: string): string {
return `<div class="__aiCard__" data-message-id="${messageId}" data-card-id="${cardId}"></div>`;
}
}
3.4 React 组件渲染
// components/AiMarkdown.tsx
import { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import { AiCardRenderer } from './AiCardRenderer';
interface AiMarkdownProps {
content: string;
messageId: string;
}
export const AiMarkdown = memo(({ content, messageId }: AiMarkdownProps) => {
const components = useMemo(
() => ({
div: ({ className, node, children, ...props }: any) => {
// 检测占位元素
if (className?.includes('__aiCard__')) {
const cardId = node?.properties?.dataCardId;
return <AiCardRenderer messageId={messageId} cardId={cardId} />;
}
return (
<div className={className} {...props}>
{children}
</div>
);
},
}),
[messageId]
);
return (
<ReactMarkdown components={components} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
);
});
// components/AiCardRenderer.tsx
import { useAiCardStore } from '../stores/ai-card-store';
import { ProductCard } from './cards/ProductCard';
import { ProductListCard } from './cards/ProductListCard';
import { ComparisonCard } from './cards/ComparisonCard';
import { ChartCard } from './cards/ChartCard';
interface Props {
messageId: string;
cardId: string;
}
/** 卡片类型到组件的映射 */
const CARD_COMPONENTS = {
product: ProductCard,
productList: ProductListCard,
comparison: ComparisonCard,
chart: ChartCard,
} as const;
export function AiCardRenderer({ messageId, cardId }: Props) {
// 从全局状态获取卡片数据
const card = useAiCardStore((state) => state.getCard(messageId, cardId));
if (!card) {
return <div className='ai-card-loading'>加载中...</div>;
}
const Component = CARD_COMPONENTS[card.type];
if (!Component) {
console.warn(`未知的卡片类型: ${card.type}`);
return null;
}
// 解析 JSON 数据
let data;
try {
data = JSON.parse(card.data.content);
} catch (e) {
return <div className='ai-card-error'>数据解析错误</div>;
}
return <Component data={data} cardId={cardId} />;
}
3.5 状态管理(Zustand 示例)
// stores/ai-card-store.ts
import { create } from 'zustand';
import type { AiCard, AiCardData } from '../types/ai-protocol';
interface CardState {
/** messageId -> cardId -> AiCard */
cards: Map<string, Map<string, Partial<AiCard>>>;
/** 开始一个新卡片 */
openCard: (messageId: string, card: AiCardData) => void;
/** 更新卡片数据 */
updateCardData: (messageId: string, cardId: string, content: string) => void;
/** 完成卡片 */
closeCard: (messageId: string, card: AiCard) => void;
/** 获取卡片 */
getCard: (messageId: string, cardId: string) => AiCard | undefined;
}
export const useAiCardStore = create<CardState>((set, get) => ({
cards: new Map(),
openCard: (messageId, card) => {
set((state) => {
const newCards = new Map(state.cards);
if (!newCards.has(messageId)) {
newCards.set(messageId, new Map());
}
newCards.get(messageId)!.set(card.id, card);
return { cards: newCards };
});
},
updateCardData: (messageId, cardId, content) => {
set((state) => {
const newCards = new Map(state.cards);
const messageCards = newCards.get(messageId);
if (messageCards) {
const card = messageCards.get(cardId);
if (card) {
messageCards.set(cardId, {
...card,
data: { format: 'json', content },
});
}
}
return { cards: newCards };
});
},
closeCard: (messageId, card) => {
set((state) => {
const newCards = new Map(state.cards);
if (!newCards.has(messageId)) {
newCards.set(messageId, new Map());
}
newCards.get(messageId)!.set(card.id, card);
return { cards: newCards };
});
},
getCard: (messageId, cardId) => {
const messageCards = get().cards.get(messageId);
return messageCards?.get(cardId) as AiCard | undefined;
},
}));
3.6 整合使用
// hooks/useAiStream.ts
import { useCallback, useRef } from 'react';
import { AiMessageParser } from '../lib/ai-message-parser';
import { useAiCardStore } from '../stores/ai-card-store';
export function useAiStream() {
const { openCard, closeCard, updateCardData } = useAiCardStore();
const parserRef = useRef<AiMessageParser>();
// 初始化解析器
const initParser = useCallback(() => {
parserRef.current = new AiMessageParser({
onCardOpen: ({ messageId, id, type }) => {
openCard(messageId, { id, type });
},
onCardClose: ({ messageId, ...card }) => {
closeCard(messageId, card);
},
onDataChunk: ({ cardId, chunk }) => {
// 可选:实时更新数据(用于大数据流式展示)
},
onDataEnd: ({ cardId, content }) => {
// 数据完成时更新
},
});
}, [openCard, closeCard]);
// 处理流式消息
const processChunk = useCallback(
(messageId: string, fullText: string) => {
if (!parserRef.current) {
initParser();
}
return parserRef.current!.parse(messageId, fullText);
},
[initParser]
);
return { processChunk, initParser };
}
四、Prompt 设计模板
export const getCustomSystemPrompt = () => `
你是一个智能购物助手,帮助用户发现和比较商品。
<output_format>
当需要展示商品信息时,使用以下 XML 标签格式:
1. 单个商品展示:
<aiCard id="唯一标识" type="product">
<aiData format="json">
{
"id": "商品ID",
"name": "商品名称",
"price": 价格数字,
"originalPrice": 原价(可选),
"image": "图片URL",
"rating": 评分,
"sales": 销量,
"tags": ["标签1", "标签2"]
}
</aiData>
</aiCard>
2. 商品列表展示:
<aiCard id="唯一标识" type="productList">
<aiData format="json">
{
"title": "列表标题",
"products": [商品对象数组]
}
</aiData>
</aiCard>
3. 商品对比表格:
<aiCard id="唯一标识" type="comparison">
<aiData format="json">
{
"products": [商品对象数组],
"dimensions": ["对比维度1", "对比维度2"]
}
</aiData>
</aiCard>
4. 数据图表:
<aiCard id="唯一标识" type="chart">
<aiData format="json">
{
"chartType": "line|bar|pie",
"title": "图表标题",
"data": { ... }
}
</aiData>
</aiCard>
</output_format>
<guidelines>
1. id 使用 kebab-case 格式,要有语义,如 "iphone-recommendation"
2. 价格使用数字类型,不要带货币符号
3. 图片 URL 必须是完整的 https 链接
4. 可以在卡片前后添加文字说明
5. 一次回复可以包含多个卡片
</guidelines>
<examples>
<example>
<user>推荐一款 5000 元左右的手机</user>
<assistant>
根据您的预算,我为您推荐以下手机:
<aiCard id="phone-rec-1" type="product">
<aiData format="json">
{
"id": "xiaomi-14",
"name": "小米14",
"price": 4999,
"image": "https://example.com/xiaomi14.jpg",
"rating": 4.8,
"sales": 50000,
"tags": ["骁龙8Gen3", "徕卡影像", "5000万像素"]
}
</aiData>
</aiCard>
这款手机搭载最新的骁龙8Gen3处理器,拍照效果出色,非常适合您的需求。
</assistant>
</example>
</examples>
`;
五、高级特性扩展
5.1 支持更多组件类型
// 扩展卡片类型
export type CardType =
| 'product'
| 'productList'
| 'comparison'
| 'chart'
| 'form' // 动态表单
| 'carousel' // 轮播图
| 'timeline' // 时间线
| 'codePreview' // 代码预览
| 'mapLocation' // 地图定位
| 'videoPlayer'; // 视频播放器
// 对应的组件映射
const CARD_COMPONENTS = {
product: ProductCard,
productList: ProductListCard,
comparison: ComparisonCard,
chart: ChartCard,
form: DynamicFormCard,
carousel: CarouselCard,
timeline: TimelineCard,
codePreview: CodePreviewCard,
mapLocation: MapLocationCard,
videoPlayer: VideoPlayerCard,
};
5.2 支持交互事件
<aiCard id="survey-form" type="form">
<aiData format="json">
{
"title": "用户满意度调查",
"fields": [
{"name": "rating", "type": "rating", "label": "请评分"},
{"name": "comment", "type": "textarea", "label": "建议"}
],
"submitAction": "survey_submit",
"submitCallback": true
}
</aiData>
</aiCard>
// 支持回调的卡片组件
function DynamicFormCard({ data, cardId, onAction }) {
const handleSubmit = (formData) => {
// 触发回调,通知 AI 用户操作
onAction?.({
type: 'form_submit',
cardId,
action: data.submitAction,
payload: formData,
});
};
return <Form fields={data.fields} onSubmit={handleSubmit} />;
}
5.3 骨架屏与加载状态
// 流式加载时显示骨架屏
function AiCardRenderer({ messageId, cardId }: Props) {
const card = useAiCardStore((state) => state.getCard(messageId, cardId));
// 卡片存在但数据未完成
if (card && !card.data?.content) {
return <CardSkeleton type={card.type} />;
}
// ... 正常渲染
}
// 骨架屏组件
function CardSkeleton({ type }: { type: CardType }) {
const skeletons = {
product: <ProductCardSkeleton />,
productList: <ProductListSkeleton />,
chart: <ChartSkeleton />,
};
return skeletons[type] || <DefaultSkeleton />;
}
六、与 boltArtifact 的对比
| 特性 | boltArtifact | 自定义协议(本文) |
|---|---|---|
| 主要用途 | 代码生成与执行 | 业务组件渲染 |
| Action 类型 | file, shell | 可自定义(product, chart...) |
| 数据格式 | 纯文本 | JSON/Markdown/HTML |
| 执行能力 | 文件系统 + Shell | 纯渲染 + 可选回调 |
| 复杂度 | 中等 | 可简可繁 |
七、最佳实践总结
- 命名空间隔离:使用独特前缀避免冲突
- 类型安全:完整的 TypeScript 类型定义
- 流式友好:支持增量解析,不阻塞渲染
- 组件映射:类型到组件的清晰映射关系
- 状态管理:独立的卡片状态存储
- 优雅降级:未知类型的容错处理
- 骨架屏:流式加载时的良好体验
- Prompt 工程:清晰的格式说明 + 丰富的示例
八、完整项目结构
src/
├── types/
│ └── ai-protocol.ts # 类型定义
├── lib/
│ └── ai-message-parser.ts # 流式解析器
├── stores/
│ └── ai-card-store.ts # 状态管理
├── hooks/
│ └── useAiStream.ts # 流式处理 Hook
├── components/
│ ├── AiMarkdown.tsx # Markdown 渲染器
│ ├── AiCardRenderer.tsx # 卡片分发器
│ └── cards/
│ ├── ProductCard.tsx
│ ├── ProductListCard.tsx
│ ├── ComparisonCard.tsx
│ └── ChartCard.tsx
└── prompts/
└── system-prompt.ts # 系统提示词
通过本文的实践,你可以快速搭建一套适合自己业务的 AI 组件渲染系统。核心思路是:定义协议 → 实现解析器 → 组件映射 → Prompt 引导。
如有问题,欢迎讨论交流!