Render as markdown content to show rich text!
Link: Ant Design X
用于聊天的气泡组件。
import { Bubble } from 'ant-design-x-vue'基础用法。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleBasicV2' });
import { AntDesignOutlined, CopyOutlined, CheckOutlined, RedoOutlined } from '@ant-design/icons-vue';
import { Bubble, Actions } from 'ant-design-x-vue';
import { Avatar } from 'ant-design-vue';
import { ref, h } from 'vue';
const copiedKey = ref('');
const actionItems = (content: string) => [
{
key: 'copy',
icon: copiedKey.value === content ? <CheckOutlined /> : <CopyOutlined />,
label: 'Copy',
onItemClick: () => {
navigator.clipboard.writeText(content);
copiedKey.value = content;
setTimeout(() => { copiedKey.value = ''; }, 2000);
},
},
{
key: 'retry',
icon: <RedoOutlined />,
label: 'Retry',
},
];
const text = `Hello World\nNext line\nTab\tindent`;
defineRender(() => (
<Bubble
content={text}
typing={{ effect: 'fade-in' }}
header={<h5>Ant Design X</h5>}
footer={(content) => (
<Actions items={actionItems(content as string)} onClick={() => console.log(content)} />
)}
avatar={<Avatar icon={<AntDesignOutlined />} />}
/>
));
</script>
通过 loading 属性控制加载状态。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleLoadingV2' });
import { ref } from 'vue';
import { Bubble } from 'ant-design-x-vue';
import { Flex, Switch } from 'ant-design-vue';
const loading = ref(true);
defineRender(() => (
<Flex gap="large" vertical>
<Bubble loading={loading.value} content="hello world !" />
<Flex gap="large" wrap>
Loading state:
<Switch checked={loading.value} onChange={(val: boolean) => (loading.value = val)} />
</Flex>
</Flex>
));
</script>
通过 variant 属性设置气泡的样式变体,通过 shape 属性设置气泡的形状。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleVariantAndShapeV2' });
import { Bubble } from 'ant-design-x-vue';
import { Flex } from 'ant-design-vue';
defineRender(() => (
<Flex vertical gap="small" class="ax-bubble-variants">
<Flex gap="small" wrap>
<Bubble content="filled - default" />
<Bubble content="filled - round" shape="round" />
<Bubble content="filled - corner left" shape="corner" />
<Bubble content="filled - corner right" shape="corner" placement="end" />
</Flex>
<Flex gap="small" wrap>
<Bubble content="outlined - default" variant="outlined" />
<Bubble content="outlined - round" variant="outlined" shape="round" />
<Bubble content="outlined - corner left" variant="outlined" shape="corner" />
<Bubble content="outlined - corner right" variant="outlined" shape="corner" placement="end" />
</Flex>
<Flex gap="small" wrap>
<Bubble content="shadow - default" variant="shadow" />
<Bubble content="shadow - round" variant="shadow" shape="round" />
<Bubble content="shadow - corner left" variant="shadow" shape="corner" />
<Bubble content="shadow - corner right" variant="shadow" shape="corner" placement="end" />
</Flex>
<Flex gap="small" wrap style={{ marginTop: '8px' }}>
<Bubble content={<span>borderless bubble</span>} variant="borderless" />
</Flex>
</Flex>
));
</script>
<style>
.ax-bubble-variants :deep(.ant-bubble) {
flex: none;
max-width: 100%;
}
</style>
通过 avatar 设置自定义头像,通过 placement 设置位置,通过 extra 设置额外内容。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleSiderAndPlacementV2' });
import { CopyOutlined, UserOutlined } from '@ant-design/icons-vue';
import { Bubble } from 'ant-design-x-vue';
import { Avatar, Flex, Tooltip } from 'ant-design-vue';
defineRender(() => (
<Flex vertical gap="small">
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="align left"
avatar={
<Tooltip title="main side">
<Avatar icon={<UserOutlined />} />
</Tooltip>
}
extra={
<Tooltip title="extra side">
<CopyOutlined />
</Tooltip>
}
/>
</div>
</Flex>
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="align right"
placement="end"
avatar={
<Tooltip title="main side">
<Avatar icon={<UserOutlined />} />
</Tooltip>
}
extra={
<Tooltip title="extra side">
<CopyOutlined />
</Tooltip>
}
/>
</div>
</Flex>
</Flex>
));
</script>
通过 header 属性设置气泡的头部。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleHeaderV2' });
import { UserOutlined } from '@ant-design/icons-vue';
import { Bubble } from 'ant-design-x-vue';
import { Avatar, Flex } from 'ant-design-vue';
defineRender(() => (
<Flex vertical gap="small">
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble content="align left" header="header" avatar={<Avatar icon={<UserOutlined />} />} />
</div>
</Flex>
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="align right"
placement="end"
header="header"
avatar={<Avatar icon={<UserOutlined />} />}
/>
</div>
</Flex>
</Flex>
));
</script>
通过 footer 属性设置气泡的底部,通过 footerPlacement 控制底部的位置。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleFooterV2' });
import { CopyOutlined, RedoOutlined, UserOutlined } from '@ant-design/icons-vue';
import { Bubble, Actions } from 'ant-design-x-vue';
import { Avatar, Flex } from 'ant-design-vue';
const actionItems = [
{
key: 'retry',
icon: <RedoOutlined />,
label: 'Retry',
},
{
key: 'copy',
icon: <CopyOutlined />,
label: 'Copy',
},
];
defineRender(() => (
<Flex vertical gap="small">
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="outer footer"
header="footer"
avatar={<Avatar icon={<UserOutlined />} />}
footer={(content) => <Actions items={actionItems} onClick={() => console.log(content)} />}
/>
</div>
</Flex>
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="inner footer"
placement="end"
footerPlacement="inner-end"
header="footer"
avatar={<Avatar icon={<UserOutlined />} />}
footer={(content) => <Actions items={actionItems} onClick={() => console.log(content)} />}
/>
</div>
</Flex>
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="outer footer and align right"
footerPlacement="outer-end"
header="footer"
avatar={<Avatar icon={<UserOutlined />} />}
footer={(content) => <Actions items={actionItems} onClick={() => console.log(content)} />}
/>
</div>
</Flex>
<Flex gap="small" wrap>
<div style={{ width: '100%' }}>
<Bubble
content="inner footer and align left"
placement="end"
footerPlacement="inner-start"
header="footer"
avatar={<Avatar icon={<UserOutlined />} />}
footer={(content) => <Actions items={actionItems} onClick={() => console.log(content)} />}
/>
</div>
</Flex>
</Flex>
));
</script>
使用 Bubble.System 组件展示系统消息。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleSystemV2' });
import { Bubble } from 'ant-design-x-vue';
import { Flex, Space, Typography } from 'ant-design-vue';
const text = `Hello, this is a system message`;
defineRender(() => (
<Flex gap={16} vertical>
<Bubble.System content={text} />
<Bubble.System
variant="outlined"
shape="round"
content={
<Space>
{text}
<Typography.Link>ok</Typography.Link>
</Space>
}
/>
<Bubble.System
variant="borderless"
content={
<Space>
{text}
<Typography.Link>cancel</Typography.Link>
</Space>
}
/>
</Flex>
));
</script>
使用 Bubble.Divider 组件添加分隔符。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleDividerV2' });
import { Bubble } from 'ant-design-x-vue';
import { Flex } from 'ant-design-vue';
defineRender(() => (
<Flex gap={16} vertical>
<Bubble content="message 1" />
<Bubble.Divider content="Solid" />
<Bubble content="message 2" placement="end" />
<Bubble.Divider content="Dashed" dividerProps={{ variant: 'dashed' }} />
<Bubble content="message 3" />
<Bubble.Divider content="Dotted" dividerProps={{ variant: 'dotted' }} />
<Bubble content="message 4" placement="end" />
<Bubble.Divider content="Plain Text" dividerProps={{ plain: true }} />
<Bubble content="message 5" />
</Flex>
));
</script>
通过 typing 属性配置动画效果,支持 fade-in 和 typing 两种效果。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleAnimationV2' });
import { ref } from 'vue';
import { CopyOutlined, RedoOutlined, UserOutlined } from '@ant-design/icons-vue';
import { Bubble, Actions } from 'ant-design-x-vue';
import { Avatar, Button, Divider, Flex, RadioGroup, RadioButton, Switch } from 'ant-design-vue';
const text = 'Ant Design X - Better UI toolkit for your AI Chat WebApp. '.repeat(5);
const text2 = 'Ant Design X - Build your AI Chat WebApp with an easier way. '.repeat(5);
const actionItems = [
{
key: 'retry',
icon: <RedoOutlined />,
label: 'Retry',
},
{
key: 'copy',
icon: <CopyOutlined />,
label: 'Copy',
},
];
const loading = ref(true);
const data = ref('');
const effect = ref<'fade-in' | 'typing'>('fade-in');
const keepPrefix = ref(false);
// The same string does not change `content`, so remount to replay it.
// A different string stays mounted so `keepPrefix` can keep the shared head.
const playback = ref(0);
const loadText = (next: string) => {
loading.value = false;
if (data.value === next) playback.value += 1;
data.value = next;
};
const loadAll = () => loadText(text);
const replaceText = () => loadText(text2);
defineRender(() => (
<Flex vertical gap="small">
<Flex gap="small" align="center">
<span>非流式数据 / Non-streaming data:</span>
<Button type="primary" onClick={loadAll}>
load data-1
</Button>
<Button onClick={replaceText}>load data-2</Button>
</Flex>
<Flex gap="small" align="center">
<span>动画效果 / Animation effects:</span>
<RadioGroup value={effect.value} onChange={(e: any) => (effect.value = e.target.value)}>
<RadioButton value="fade-in">fade-in</RadioButton>
<RadioButton value="typing">typing</RadioButton>
</RadioGroup>
</Flex>
<Flex gap="small" align="center">
<span>保留公共前缀 / Preserve common prefix:</span>
<Switch checked={keepPrefix.value} onChange={(val: boolean) => (keepPrefix.value = val)} />
</Flex>
<Divider />
<Flex gap="small" align="center">
<Bubble
key={playback.value}
loading={loading.value}
content={data.value}
typing={{ effect: effect.value, interval: 50, step: 3, keepPrefix: keepPrefix.value }}
header={<h5>ADX</h5>}
footer={(content) => <Actions items={actionItems} onClick={() => console.log(content)} />}
avatar={<Avatar icon={<UserOutlined />} />}
onTyping={() => console.log('typing')}
onTypingComplete={() => console.log('typing complete')}
/>
</Flex>
</Flex>
));
</script>
通过 editable 属性开启可编辑功能。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleEditableV2' });
import { ref } from 'vue';
import { EditOutlined, UserOutlined } from '@ant-design/icons-vue';
import { Bubble, Actions } from 'ant-design-x-vue';
import { Avatar, Flex } from 'ant-design-vue';
const editable = ref<[boolean, { editing: boolean; okText: string; cancelText: any }]>([
false,
{ editing: false, okText: 'ok', cancelText: <span>cancel</span> },
]);
const content = ref(['editable bubble 1', 'editable bubble 2']);
defineRender(() => (
<Flex vertical gap="small" style={{ minHeight: '200px' }}>
<Flex>
<Bubble
editable={editable.value[0]}
content={content.value[0]}
avatar={<Avatar icon={<UserOutlined />} />}
footer={
<Actions
items={[
{
key: 'edit',
icon: <EditOutlined />,
label: 'edit',
},
]}
onClick={({ key }) => (editable.value = [key === 'edit', editable.value[1]])}
/>
}
onEditCancel={() => (editable.value = [false, editable.value[1]])}
onEditConfirm={(val: string) => {
content.value = [val, content.value[1]];
editable.value = [false, editable.value[1]];
}}
/>
</Flex>
<Flex>
<Bubble
style={{ width: '100%' }}
placement="end"
editable={editable.value[1]}
content={content.value[1]}
avatar={<Avatar icon={<UserOutlined />} />}
footer={
<Actions
items={[
{
key: 'edit',
icon: <EditOutlined />,
label: 'edit',
},
]}
onClick={({ key }) =>
(editable.value = [editable.value[0], { ...editable.value[1], editing: key === 'edit' }])
}
/>
}
onEditCancel={() => (editable.value = [editable.value[0], { ...editable.value[1], editing: false }])}
onEditConfirm={(val: string) => {
content.value = [content.value[0], val];
editable.value = [editable.value[0], { ...editable.value[1], editing: false }];
}}
/>
</Flex>
</Flex>
));
</script>
通过 streaming 属性标识流式传输状态,配合动画效果使用。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleStreamV2' });
import { ref, watch, onUnmounted, computed } from 'vue';
import { UserOutlined } from '@ant-design/icons-vue';
import { Bubble, type BubbleProps } from 'ant-design-x-vue';
import { Avatar, Button, Divider, Flex, Switch, Typography } from 'ant-design-vue';
const text = 'Ant Design X - Better UI toolkit for your AI Chat WebApp. '.repeat(5);
// 模拟流式内容
const streamContent = ref('');
const isDone = ref(true);
let timer: number | null = null;
const startStream = (content: string, step: number, interval: number) => {
isDone.value = false;
streamContent.value = '';
let currentLength = 0;
if (timer) clearInterval(timer);
timer = setInterval(() => {
currentLength += step;
if (currentLength <= content.length - 1) {
streamContent.value = content.slice(0, currentLength);
} else {
streamContent.value = content;
isDone.value = true;
if (timer) clearInterval(timer);
}
}, interval) as any;
};
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const loading = ref(true);
const data = ref('');
const typing = ref(false);
const disableStreaming = ref(false);
const count = ref(0);
const typingConfig: BubbleProps['typing'] = {
effect: 'typing',
step: 5,
interval: 50,
keepPrefix: true,
};
const loadStream = (step: number, interval: number) => {
loading.value = false;
count.value = 0;
const newText = `${(Math.random() * 10).toFixed(0)} - ${text}`;
data.value = newText;
startStream(newText, step, interval);
};
const handleClear = () => {
data.value = '';
streamContent.value = '';
isDone.value = true;
if (timer) clearInterval(timer);
};
defineRender(() => (
<Flex vertical gap="small">
<Flex gap="small" align="center">
<span>流式数据 / steaming data:</span>
<Button type="primary" onClick={() => loadStream(2, 100)}>
load slowly
</Button>
<Button onClick={() => loadStream(10, 50)}>load quickly</Button>
<Button type="link" onClick={handleClear}>
clear
</Button>
</Flex>
<Flex gap="small" align="center">
<span>强制关闭流式标志 / Force close the streaming flag: </span>
<Switch checked={disableStreaming.value} onChange={(val: boolean) => (disableStreaming.value = val)} />
</Flex>
<Flex gap="small" align="center">
<span>启用动画 / Enable animation:</span>
<Switch checked={typing.value} onChange={(val: boolean) => (typing.value = val)} />
</Flex>
<Flex gap="small" align="center">
<span>
onTypingComplete 触发次数 / trigger times:{' '}
<Typography.Text type="danger">{count.value}</Typography.Text>
</span>
</Flex>
<Divider />
<Flex gap="small" align="center">
<Bubble
loading={loading.value}
content={streamContent.value}
streaming={disableStreaming.value ? false : !isDone.value}
typing={typing.value ? typingConfig : false}
header={<h5>ADX</h5>}
avatar={<Avatar icon={<UserOutlined />} />}
onTyping={() => console.log('typing')}
onTypingComplete={() => {
count.value++;
console.log('typing complete');
}}
/>
</Flex>
</Flex>
));
</script>
通过 contentRender 自定义渲染内容。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleCustomContentV2' });
import { ref } from 'vue';
import { Bubble } from 'ant-design-x-vue';
import { Button, Flex, Image } from 'ant-design-vue';
type ContentType = {
imageUrl: string;
text: string;
actionNode: any;
};
const content = ref<ContentType>({
imageUrl:
'https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original',
text: 'Ant Design X',
actionNode: <>Click Me</>,
});
defineRender(() => (
<Bubble
content={content.value}
contentRender={(c) => {
return (
<Flex gap="middle" align="center">
<Image height={50} src={(c as ContentType).imageUrl} />
<span style={{ fontSize: '18px', fontWeight: 'bold' }}>{(c as ContentType).text}</span>
</Flex>
);
}}
footer={(c) => {
return (
<Button
onClick={() => {
content.value = {
...content.value,
actionNode: <>🎉 Happy Ant Design X !</>,
};
}}
type="text"
>
{(c as ContentType)?.actionNode}
</Button>
);
}}
/>
));
</script>
Render as markdown content to show rich text!
Link: Ant Design X
配合 Markdown 渲染实现富文本内容。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleMarkdownV2' });
import { ref, watchEffect, type CSSProperties } from 'vue';
import { Bubble } from 'ant-design-x-vue';
import { XMarkdown } from '@ant-design-x-vue/x-markdown';
import { Button, Flex, theme, Typography } from 'ant-design-vue';
const text = `
> Render as markdown content to show rich text!
Link: [Ant Design X](https://x.ant.design)
`.trim();
const { token } = theme.useToken();
const index = ref(text.length);
const isServer = import.meta.env.SSR;
watchEffect((onCleanup) => {
if (!isServer && index.value < text.length) {
const timerId = setTimeout(() => {
index.value += 5;
}, 20);
onCleanup(() => clearTimeout(timerId));
}
});
defineRender(() => (
<Flex vertical gap={16}>
<Flex>
<Button type="primary" onClick={() => (index.value = 1)}>
rerender
</Button>
</Flex>
<Flex>
<Bubble
content={text.slice(0, index.value)}
contentRender={(content) => (
<Typography
class="ax-bubble-md"
style={{
'--ax-link': token.value.colorLink,
'--ax-link-hover': token.value.colorLinkHover,
} as CSSProperties}
>
<XMarkdown content={content as string} />
</Typography>
)}
/>
</Flex>
</Flex>
));
</script>
<style>
/* Match the React Bubble markdown demo: antd Typography link, no glyph.
`.vp-doc a` underlines and recolors anchors, and `.x-markdown-light a::after`
appends a superscript ↗. */
.vp-doc .example-showcase .ax-bubble-md a {
color: var(--ax-link, #1677ff);
font-weight: 400;
text-decoration: none;
}
.vp-doc .example-showcase .ax-bubble-md a:hover,
.vp-doc .example-showcase .ax-bubble-md a:active {
color: var(--ax-link-hover, var(--ax-link, #1677ff));
text-decoration: none;
}
.vp-doc .example-showcase .ax-bubble-md a::before,
.vp-doc .example-showcase .ax-bubble-md a::after {
content: none !important;
}
.vp-doc .example-showcase .ax-bubble-md p,
.vp-doc .example-showcase .ax-bubble-md blockquote > p {
margin: 0;
line-height: 1.5714285714;
font-size: inherit;
color: inherit;
}
.vp-doc .example-showcase .ax-bubble-md blockquote {
margin: 1em 0;
padding: 0 0 0 0.6em;
border: 0;
border-inline-start: 4px solid rgba(100, 100, 100, 0.2);
background: transparent;
color: inherit;
opacity: 0.85;
}
</style>
在 contentRender 里用 @antv/gpt-vis 渲染模型输出的图表。vis 代码块在浏览器里交给 new GPTVis({ container }).render(),组件卸载时调用 destroy(),服务端渲染不会创建画布。
GPT-Vis, Components for GPTs, generative AI, and LLM projects. Not only UI Components. more...
Here's a visualization of Haidilao's food delivery revenue from 2013 to 2022. You can see a steady increase over the years, with notable growth particularly in recent years.
用 @antv/gpt-vis 在气泡里画出海底捞外卖营收折线。Re-Render 会重新流式输出。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleGptVisV2' });
import {
defineComponent,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
watchEffect,
} from 'vue';
import { Bubble } from 'ant-design-x-vue';
import { XMarkdown } from '@ant-design-x-vue/x-markdown';
import { Button, Flex, Typography } from 'ant-design-vue';
import { splitVisMarkdown, toRenderSyntax } from '../../shared/vis-markdown';
const text = `
**GPT-Vis**, Components for GPTs, generative AI, and LLM projects. Not only UI Components. [more...](https://github.com/antvis/GPT-Vis)
Here's a visualization of Haidilao's food delivery revenue from 2013 to 2022. You can see a steady increase over the years, with notable *growth* particularly in recent years.
\`\`\`vis
vis line
data
- time 2013
value 59.3
- time 2014
value 64.4
- time 2015
value 68.9
- time 2016
value 74.4
- time 2017
value 82.7
- time 2018
value 91.9
- time 2019
value 99.1
- time 2020
value 101.6
- time 2021
value 114.4
- time 2022
value 121
title Haidilao delivery revenue
axisXTitle year
axisYTitle sale
\`\`\`
`.trim();
type VisChart = { render: (value: string) => void; destroy: () => void };
const GptVisChart = defineComponent({
name: 'GptVisChart',
props: { syntax: { type: String, required: true } },
setup(props) {
const host = ref<HTMLDivElement | null>(null);
let chart: VisChart | null = null;
let painted = '';
let disposed = false;
const paint = (syntax: string) => {
const value = toRenderSyntax(syntax);
if (!chart || !value || value === painted) return;
try {
chart.render(value);
painted = value;
} catch (error) {
console.warn(error);
}
};
onMounted(() => {
void (async () => {
await nextTick();
if (disposed || !host.value) return;
const { GPTVis } = await import('@antv/gpt-vis');
if (disposed || !host.value) return;
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
chart = new GPTVis({
container: host.value,
width: host.value.clientWidth || 640,
height: 320,
theme,
});
paint(props.syntax);
})();
});
watch(() => props.syntax, (syntax) => paint(syntax));
onBeforeUnmount(() => {
disposed = true;
chart?.destroy();
chart = null;
});
return () => <div ref={host} class="ax-gpt-vis" />;
},
});
const renderContent = (content: unknown) => {
const part = splitVisMarkdown(String(content ?? ''));
return (
<Typography class="ax-bubble-gpt-vis">
{part.before.trim() ? <XMarkdown content={part.before} /> : null}
{part.syntax.trim().startsWith('vis ') ? <GptVisChart syntax={part.syntax} /> : null}
{part.after.trim() ? <XMarkdown content={part.after} /> : null}
</Typography>
);
};
const index = ref(text.length);
const isServer = import.meta.env.SSR;
watchEffect((onCleanup) => {
if (isServer || index.value >= text.length) return;
const timerId = setTimeout(() => {
index.value = Math.min(text.length, index.value + 5);
}, 20);
onCleanup(() => clearTimeout(timerId));
});
defineRender(() => (
<Flex vertical gap="small">
<Button style={{ alignSelf: 'flex-end' }} onClick={() => (index.value = 0)}>
Re-Render
</Button>
<Bubble
content={text.slice(0, index.value)}
contentRender={renderContent}
variant="outlined"
/>
</Flex>
));
</script>
<style>
.vp-doc .example-showcase .ax-bubble-gpt-vis a {
font-weight: 400;
text-decoration: none;
}
.vp-doc .example-showcase .ax-bubble-gpt-vis a::before,
.vp-doc .example-showcase .ax-bubble-gpt-vis a::after {
content: none !important;
}
.vp-doc .example-showcase .ax-bubble-gpt-vis p {
margin: 0 0 0.6em;
line-height: 1.7;
}
.ax-gpt-vis {
width: 100%;
min-height: 320px;
}
</style>
预设样式的气泡列表,支持 System 和 Divider 类型。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleListV2' });
import { computed, nextTick, onMounted, ref, type CSSProperties } from 'vue';
import {
AntDesignOutlined,
CheckOutlined,
CopyOutlined,
EditOutlined,
LinkOutlined,
RedoOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import {
Actions,
Bubble,
FileCard,
type BubbleItemType,
type BubbleListRef,
type FileCardProps,
type RoleType,
} from 'ant-design-x-vue';
import { XMarkdown } from '@ant-design-x-vue/x-markdown';
import { Avatar, Button, Flex, Space, Switch, theme, Typography } from 'ant-design-vue';
const { token } = theme.useToken();
const actionItems = [
{
key: 'retry',
icon: <RedoOutlined />,
label: 'Retry',
},
{
key: 'copy',
icon: <CopyOutlined />,
label: 'Copy',
},
];
let id = 0;
const getKey = () => `bubble_${id++}`;
const genItem = (isAI: boolean, config?: Partial<BubbleItemType>): BubbleItemType => ({
key: getKey(),
role: isAI ? 'ai' : 'user',
content: `${id} : ${isAI ? 'Mock AI content'.repeat(50) : 'Mock user content.'}`,
...config,
});
const text = `
> Render as markdown content to show rich text!
Link: [Ant Design X](https://x.ant.design)
`.trim();
const items = ref<BubbleItemType[]>([]);
const enableLocScroll = ref(true);
const autoScroll = ref(true);
const listRef = ref<BubbleListRef | null>(null);
const update = (key: string | number, data: Partial<BubbleItemType>) => {
items.value = items.value.map((item) => (item.key === key ? { ...item, ...data } : item));
};
const add = (item: BubbleItemType) => {
items.value = [...items.value, item];
};
onMounted(() => {
items.value = [
{ key: getKey(), role: 'system', content: 'Welcome to use Ant Design X' },
genItem(false, { typing: false }),
genItem(true, { typing: false }),
{ key: getKey(), role: 'divider', content: 'divider' },
genItem(false, { typing: false }),
genItem(true, { typing: false, loading: true }),
];
});
const roles = computed<RoleType>(() => ({
ai: {
typing: true,
header: 'AI',
avatar: () => <Avatar icon={<AntDesignOutlined />} />,
footer: (content) => <Actions items={actionItems} onClick={() => console.log(content)} />,
},
user: (data) => ({
placement: 'end',
typing: false,
header: `User-${data.key}`,
avatar: () => <Avatar icon={<UserOutlined />} />,
footer: () => (
<Actions
items={[
data.editable
? { key: 'done', icon: <CheckOutlined />, label: 'done' }
: { key: 'edit', icon: <EditOutlined />, label: 'edit' },
]}
onClick={({ key }) => update(data.key, { editable: key === 'edit' })}
/>
),
onEditConfirm: (content: string) => {
console.log(`editing User-${data.key}: `, content);
update(data.key, { content, editable: false });
},
onEditCancel: () => {
update(data.key, { editable: false });
},
}),
reference: {
variant: 'borderless',
// 16px for list item gap
styles: { root: { margin: 0, marginBottom: -12 } },
avatar: () => '',
contentRender: (content: FileCardProps) => (
<Space>
<LinkOutlined />
<FileCard type="file" size="small" name={content.name} byte={content.byte} />
</Space>
),
},
}));
const scrollTo = (option: { top: 'top' | 'bottom' }) => {
// Vue flushes the list DOM on the next tick. Measuring before that uses the
// previous scroll height, so the jump misses the bubble that was just added.
nextTick(() => {
listRef.value?.scrollTo({ ...option, behavior: 'smooth' });
});
};
const addBubble = () => {
const chatItems = items.value.filter((item) => item.role === 'ai' || item.role === 'user');
const isAI = !!(chatItems.length % 2);
add(genItem(isAI, { typing: { effect: 'fade-in', step: [20, 50] } }));
if (enableLocScroll.value) {
scrollTo({ top: 'bottom' });
}
};
const addMarkdown = () => {
add({
key: getKey(),
role: 'ai',
typing: { effect: 'fade-in', step: 6 },
content: text,
contentRender: (content: string) => (
<Typography
class="ax-bubble-md"
style={
{
'--ax-link': token.value.colorLink,
'--ax-link-hover': token.value.colorLinkHover,
} as CSSProperties
}
>
<XMarkdown content={content} />
</Typography>
),
});
if (enableLocScroll.value) {
scrollTo({ top: 'bottom' });
}
};
const addDivider = () => {
items.value = [...items.value, { key: getKey(), role: 'divider', content: 'Divider' }];
if (enableLocScroll.value) {
scrollTo({ top: 'bottom' });
}
};
const addSystem = () => {
items.value = [...items.value, { key: getKey(), role: 'system', content: 'This is a system message' }];
if (enableLocScroll.value) {
scrollTo({ top: 'bottom' });
}
};
const addToTop = () => {
const item = genItem(false);
items.value = [item, genItem(true), genItem(false), ...items.value];
if (enableLocScroll.value) {
scrollTo({ top: 'top' });
}
};
const addWithRef = () => {
items.value = [
...items.value,
{
key: getKey(),
role: 'reference',
placement: 'end',
content: { name: 'Ant-Design-X.pdf' },
},
genItem(false),
];
if (enableLocScroll.value) {
scrollTo({ top: 'bottom' });
}
};
defineRender(() => (
<Flex vertical style={{ height: '720px' }} gap={20}>
<Flex vertical gap="small">
<Space align="center">
<Switch checked={autoScroll.value} onChange={(checked) => (autoScroll.value = !!checked)} />
<span>启用 autoScroll / enabled autoScroll</span>
</Space>
<Space align="center">
<Switch
checked={enableLocScroll.value}
onChange={(checked) => (enableLocScroll.value = !!checked)}
/>
<span>定位到新气泡 / locate to new bubble</span>
</Space>
</Flex>
<Flex gap="small" wrap="wrap">
<Button type="primary" onClick={addBubble}>
Add Bubble
</Button>
<Button onClick={addMarkdown}>Add Markdown</Button>
<Button onClick={addDivider}>Add Divider</Button>
<Button onClick={addSystem}>Add System</Button>
<Button onClick={addToTop}>Add To Top</Button>
<Button onClick={addWithRef}>Add With Ref</Button>
</Flex>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<Bubble.List
style={{ height: '620px' }}
ref={listRef}
roles={roles.value}
items={items.value}
autoScroll={autoScroll.value}
/>
</div>
</Flex>
));
</script>
<style>
/* Match the React Bubble markdown demo inside VitePress.
`.vp-doc a` underlines anchors, and `.x-markdown-light a::after` appends ↗. */
.vp-doc .example-showcase .ax-bubble-md a {
color: var(--ax-link, #1677ff);
font-weight: 400;
text-decoration: none;
}
.vp-doc .example-showcase .ax-bubble-md a:hover,
.vp-doc .example-showcase .ax-bubble-md a:active {
color: var(--ax-link-hover, var(--ax-link, #1677ff));
text-decoration: none;
}
.vp-doc .example-showcase .ax-bubble-md a::before,
.vp-doc .example-showcase .ax-bubble-md a::after {
content: none !important;
}
.vp-doc .example-showcase .ax-bubble-md p,
.vp-doc .example-showcase .ax-bubble-md blockquote > p {
margin: 0;
line-height: 1.5714285714;
font-size: inherit;
color: inherit;
}
.vp-doc .example-showcase .ax-bubble-md blockquote {
margin: 1em 0;
padding: 0 0 0 0.6em;
border: 0;
border-inline-start: 4px solid rgba(100, 100, 100, 0.2);
background: transparent;
color: inherit;
opacity: 0.85;
}
</style>
列表支持 status、extraInfo 等扩展功能。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleListExtraV2' });
import { ref, computed } from 'vue';
import { Bubble, Actions, type BubbleItemType, type RoleType } from 'ant-design-x-vue';
import { Flex, Spin } from 'ant-design-vue';
const items = ref<BubbleItemType[]>([
{
status: 'success',
key: 'welcome',
role: 'ai',
variant: 'borderless',
content: 'Mock welcome content. '.repeat(10),
extraInfo: {
feedback: 'like',
},
},
{
key: 'ask',
role: 'user',
content: 'Mock user content.',
},
{
key: 'ai_0',
role: 'ai',
status: 'success',
variant: 'borderless',
content: 'Mock welcome content. '.repeat(10),
extraInfo: {
feedback: 'dislike',
},
},
{
key: 'user_1',
role: 'user',
content: 'Mock user content.',
},
{
key: 'ai_1',
role: 'ai',
variant: 'borderless',
status: 'success',
content: 'Mock welcome content. '.repeat(10),
extraInfo: {
feedback: 'dislike',
},
},
{
key: 'user_2',
role: 'user',
content: 'Mock user content.',
},
{
key: 'ai_2',
role: 'ai',
variant: 'borderless',
status: 'success',
content: 'Mock welcome content. '.repeat(10),
extraInfo: {
feedback: 'like',
},
},
{
key: 'user_3',
role: 'user',
content: 'Mock user content.',
},
{
key: 'ai_3',
role: 'ai',
status: 'loading',
loading: true,
content: '',
},
]);
const roles = computed<RoleType>(() => ({
ai: {
placement: 'start',
typing: (_, { status }) =>
status === 'updating' ? { effect: 'typing', step: 5, interval: 20 } : false,
footer: (content, { key, extraInfo }) => (
<Actions
items={[
{
key: 'copy',
label: 'copy',
},
{
key: 'feedback',
label: extraInfo?.feedback === 'like' ? '👍' : extraInfo?.feedback === 'dislike' ? '👎' : '🤔',
},
]}
onClick={({ key: actionKey }) => {
if (actionKey === 'feedback') {
items.value = items.value.map((message) => {
if (message.key === key) {
const currentFeedback = message.extraInfo?.feedback;
const newFeedback = currentFeedback === 'like' ? 'dislike' : 'like';
return { ...message, extraInfo: { feedback: newFeedback } };
}
return message;
});
}
}}
/>
),
loadingRender: () => (
<Flex align="center" gap="small">
<Spin size="small" />
Custom loading...
</Flex>
),
},
user: {
placement: 'end',
},
}));
defineRender(() => (
<Bubble.List style={{ height: '500px' }} roles={roles.value} items={items.value} />
));
</script>
自定义 role 配置,包括自定义 loadingRender。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleSemanticListCustomV2' });
import { ref } from 'vue';
import { FrownOutlined, SmileOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { Bubble, type RoleType } from 'ant-design-x-vue';
import { Button, Flex, Spin } from 'ant-design-vue';
const listRef = ref<InstanceType<typeof Bubble.List> | null>(null);
const roles: RoleType = {
ai: {
placement: 'start',
typing: { effect: 'typing', step: 5, interval: 20 },
loadingRender: () => (
<Flex align="center" gap="small">
<Spin size="small" />
Custom loading...
</Flex>
),
},
user: {
placement: 'end',
},
};
defineRender(() => (
<Bubble.List
ref={listRef}
style={{ height: '500px' }}
roles={roles}
items={[
{
key: 'welcome',
role: 'ai',
content: 'Mock welcome content. '.repeat(10),
footer: (
<Flex>
<Button
size="small"
type="text"
icon={<SyncOutlined />}
style={{ marginInlineEnd: 'auto' }}
/>
<Button size="small" type="text" icon={<SmileOutlined />} />
<Button size="small" type="text" icon={<FrownOutlined />} />
</Flex>
),
},
{
key: 'ask',
role: 'user',
content: 'Mock user content.',
},
{
key: 'ai',
role: 'ai',
loading: true,
content: '',
},
]}
/>
));
</script>
使用 scrollTo 方法控制列表滚动。
<script setup lang="tsx">
defineOptions({ name: 'AXBubbleListScrollV2' });
import { ref, computed, onMounted } from 'vue';
import { AntDesignOutlined, CopyOutlined, RedoOutlined, UserOutlined } from '@ant-design/icons-vue';
import { Bubble, Actions, type BubbleItemType, type RoleType, type BubbleListRef } from 'ant-design-x-vue';
import { Avatar, Button, Flex } from 'ant-design-vue';
const actionItems = [
{
key: 'retry',
icon: <RedoOutlined />,
label: 'Retry',
},
{
key: 'copy',
icon: <CopyOutlined />,
label: 'Copy',
},
];
let id = 0;
const getKey = () => `bubble_${id++}`;
const genItem = (isAI: boolean, config?: Partial<BubbleItemType>): BubbleItemType => ({
key: getKey(),
role: isAI ? 'ai' : 'user',
content: `${id} : ${isAI ? 'Mock AI content'.repeat(50) : 'Mock user content.'}`,
...config,
});
const listRef = ref<BubbleListRef | null>(null);
const items = ref<BubbleItemType[]>([]);
onMounted(() => {
items.value = [
genItem(false, { typing: false }),
genItem(true, { typing: false }),
genItem(false, { typing: false }),
genItem(true, { typing: false }),
genItem(false, { typing: false }),
genItem(true, { typing: false }),
genItem(false, { typing: false }),
genItem(true, { typing: false }),
genItem(false, { typing: false }),
genItem(true, { typing: false }),
genItem(false, { typing: false }),
];
});
const roles = computed<RoleType>(() => ({
ai: {
typing: true,
header: 'AI',
avatar: () => <Avatar icon={<AntDesignOutlined />} />,
footer: (content) => <Actions items={actionItems} onClick={() => console.log(content)} />,
},
user: {
placement: 'end',
typing: false,
header: 'User',
avatar: () => <Avatar icon={<UserOutlined />} />,
},
}));
defineRender(() => (
<Flex vertical style={{ height: '720px' }} gap="small">
<Flex gap="small" justify="space-between">
<Flex gap="small">
<Button type="primary" onClick={() => console.log(listRef.value?.nativeElement)}>
Get Dom
</Button>
<Button onClick={() => listRef.value?.scrollTo({ top: 'top' })}>Scroll To Top</Button>
<Button onClick={() => listRef.value?.scrollTo({ top: 'bottom', behavior: 'smooth' })}>
Scroll To Bottom
</Button>
<Button onClick={() => listRef.value?.scrollTo({ top: Math.random() * 1000 })}>
Scroll To Random
</Button>
<Button onClick={() => listRef.value?.scrollTo({ key: items.value[1].key, block: 'nearest' })}>
Scroll To Second
</Button>
</Flex>
</Flex>
<Bubble.List
ref={listRef}
roles={roles.value}
items={items.value}
onScroll={(e: Event) => {
console.log('scroll', (e.target as HTMLDivElement).scrollTop);
}}
/>
</Flex>
));
</script>
| 属性 | 说明 | 类型 | 默认值 | 版本 |
|---|---|---|---|---|
| avatar | 展示头像 | VNode | (content, info) => VNode | - | |
| classNames | 语义化结构 class | Record<SemanticDOM, string> | - | |
| content | 聊天内容 | ContentType | - | |
| contentRender | 自定义渲染内容 | (content: ContentType, info: Info) => VNode | - | - |
| editable | 是否可编辑 | boolean | EditableBubbleOption | false | - |
| extra | 额外内容 | VNode | (content, info) => VNode | - | - |
| footer | 底部内容 | VNode | (content, info) => VNode | - | |
| footerPlacement | 底部位置 | outer-start | outer-end | inner-start | inner-end | outer-start | - |
| header | 头部内容 | VNode | (content, info) => VNode | - | |
| loading | 聊天内容加载状态 | boolean | false | |
| placement | 信息位置 | start | end | start | |
| shape | 气泡形状 | default | round | corner | default | v2 新增 default |
| streaming | 是否流式传输 | boolean | false | - |
| styles | 语义化结构 style | Record<SemanticDOM, CSSProperties> | - | |
| typing | 打字动画配置 | boolean | BubbleAnimationOption | Function | false | v2 增强 |
| variant | 气泡样式变体 | filled | borderless | outlined | shadow | filled | |
| loadingRender | 自定义渲染加载态内容 | () => VNode | - | |
| messageRender | 自定义渲染内容(已废弃,请使用 contentRender) | (content: ContentType) => VNode | - | |
| onTyping | 动画执行时回调 | (rendererContent: string, currentContent: string) => void | - | - |
| onTypingComplete | 动画结束回调 | (content?: string) => void | - | |
| onEditConfirm | 编辑确认回调 | (content: string) => void | - | - |
| onEditCancel | 编辑取消回调 | () => void | - | - |
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| effect | 动画效果类型 | typing | fade-in | fade-in |
| step | 内容步进单位,数组格式为随机区间 | number | [number, number] | 6 |
| interval | 动画触发间隔 | number | 100 |
| keepPrefix | 重新开始动画时是否保留公共前缀 | boolean | true |
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| editing | 是否处于编辑状态 | boolean | false |
| okText | 确认按钮文本 | VNode | string | 确认 |
| cancelText | 取消按钮文本 | VNode | string | 取消 |
| 属性 | 说明 | 类型 |
|---|---|---|
| key | 数据项唯一标识 | string | number |
| status | 消息状态 | local | loading | updating | success | error | abort |
| extraInfo | 额外信息 | AnyObject |
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| content | 系统消息内容 | ContentType | - |
| variant | 气泡样式变体 | filled | borderless | outlined | shadow | shadow |
| shape | 气泡形状 | default | round | corner | - |
| styles | 语义化结构 style | Record<SemanticDOM, CSSProperties> | - |
| classNames | 语义化结构 class | Record<SemanticDOM, string> | - |
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| content | 分隔符内容 | ContentType | - |
| dividerProps | 分隔符属性 | DividerProps | - |
| styles | 语义化结构 style | Record<SemanticDOM, CSSProperties> | - |
| classNames | 语义化结构 class | Record<SemanticDOM, string> | - |
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| autoScroll | 自动滚动到最新位置 | boolean | true |
| items | 气泡数据列表 | BubbleItemType[] | - |
| roles | 气泡默认属性配置,与 role 等价。同时传入时优先 roles | RoleType | - |
| role | 与 React 2.0 Bubble.List 的 role 相同 | RoleType | - |
| styles | 语义化结构 style | Record<SemanticDOM, CSSProperties> | - |
| classNames | 语义化结构 class | Record<SemanticDOM, string> | - |
| onScroll | 滚动事件回调 | (e: Event) => void | - |
| 属性 | 说明 | 类型 |
|---|---|---|
| key | 数据项唯一标识(必填) | string | number |
| role | 角色类型 | ai | user | system | divider | string |
| status | 消息状态 | local | loading | updating | success | error | abort |
| extraInfo | 额外信息 | AnyObject |
| ...BubbleProps | 继承 Bubble 的所有属性 | - |
| 方法 | 说明 | 类型 |
|---|---|---|
| nativeElement | 原生 DOM 元素 | HTMLDivElement |
| scrollBoxNativeElement | 滚动容器 DOM | HTMLDivElement |
| scrollTo | 滚动到指定位置 | (options: ScrollToOptions) => void |
| 属性 | 说明 | 类型 |
|---|---|---|
| key | 滚动到指定 key 的气泡 | string | number |
| top | 滚动位置 | number | bottom | top |
| offset | 滚动偏移量 | number |
| behavior | 滚动行为 | smooth | instant | auto |
| block | 滚动对齐方式 | start | center | end | nearest |
| 插槽名 | 说明 | 类型 |
|---|---|---|
| avatar | 头像 | - |
| header | 头部面板 | |
| footer | 底部内容 | |
| extra | 额外内容 | |
| loading | loading 占位 | - |
| content | 消息内容 |
| 插槽名 | 说明 | 类型 |
|---|---|---|
| avatar | 头像 | |
| header | 头部面板 | |
| footer | 底部内容 | |
| extra | 额外内容 | |
| loading | loading 占位 | |
| content | 消息内容 |
| Token | 说明 | 类型 | 默认值 |
|---|---|---|---|
| typingContent | 打字动画内容 | string | | |
| typingAnimationDuration | 打字动画持续时间 | string | 0.8s |
| typingAnimationName | 打字动画名称 | string | cursorBlink |