跳到主要内容

从 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 标签冲突(如 boltaibiz
  • ComponentType:组件容器类型
  • ActionType:具体操作或子组件类型
  • attributes:元数据,用于标识和配置
  • content:实际内容数据

2.2 命名规范建议

元素规范示例
命名空间小写,2-4 字母aibizapp
组件标签PascalCaseAiCardBizChart
属性名camelCasedataTypechartId
ID 值kebab-caseproduct-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纯渲染 + 可选回调
复杂度中等可简可繁

七、最佳实践总结

  1. 命名空间隔离:使用独特前缀避免冲突
  2. 类型安全:完整的 TypeScript 类型定义
  3. 流式友好:支持增量解析,不阻塞渲染
  4. 组件映射:类型到组件的清晰映射关系
  5. 状态管理:独立的卡片状态存储
  6. 优雅降级:未知类型的容错处理
  7. 骨架屏:流式加载时的良好体验
  8. 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 引导

如有问题,欢迎讨论交流!