说在前面
现在大模型如此火热,想必你跟我也有同样的想法,实现一个自己的AI
对话框,相比Dify
等组件分享出来的对话框,自己实现起来可以更加灵活和适应需求。
虽然Element
,Antd
都发布了各自的对话框组件,我说句实话,这个理解起来真没之前那种Button
,Card
这些组件来的简单,下面分享我的一个小Demo
。
功能拆解
首先,官方帮我们实现了一个小的原型,附带了几乎所有的功能,地址如下:
ant-design-v-vue样例
相信这个页面能够帮助大家更快的理解每个组件之间的作用机制。
然后,我们需要做的就是接入我们自己的大模型服务,通过官方的框架实现AI
对话。
一些理解
在接入大模型之前分享一下对于这个框架的理解,不感兴趣可以直接跳过。
- 组件的封装考虑了很多一般人不会考虑的东西,所以相对抽象了一些
- 实现接入大模型服务的核心在“工具”这里
useXAgent
是智能体,负责大模型请求的发起,所以叫模型调度
useXChat
是对话的管理,其中的onRequest
关联了useXAgent
的request
,当用户调用onRequest
时,会触发agent实例中的request
来与后端API
进行交互。 - 对话内容我要如何保存?
useXChat
有一个messages
属性,会自动记录用户发送以及大模型传递过来的消息,只需调用方法触发更新。
实现
首先就是要定义useXAgent
,上面说过,他负责大模型请求的发起。以下代码主要是这么几个点:
- 封装阿里云百炼平台的请求,添加认证信息,要求以流式返回
- 使用
getReader()
方法读取大模型的返回 - 当持续有返回时,通过while循环重复读取,完成后跳出循环
- 一次
reader.read()
不是eventStream
的一行,而是多行,举例:向大模型问好,他回复也会简单,一次reader.read()
就可以包含所有的返回 - 判断
line === data: [DONE]
是提前监测到结束,避免json
转化报错。 - onUpdate方法用于实时更新最新的返回内容,实现流式返回
const [agent] = useXAgent({
request: async ({ message }, { onSuccess, onUpdate, onError }) => {
debugger
try {
const response = await fetch(''https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-api-key'
},
body: JSON.stringify({
messages: [{ role: 'user', content: message }],
stream: true,
model: 'qwen3-32b',
}),
});
let contentt = '';
// 由于 Response 对象没有 data 属性,需要使用 response.body 来处理流式响应
const reader = response.body?.getReader();
if (reader) {
status.value = 'success';
const decoder = new TextDecoder('utf-8');
const readStream = async () => {
while (true) {
// 单次read并不是eventstream的一行,理论上应该是多行,简单地问题一次可能就全部读取完毕,复杂的问题需要读取多次
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const dataStr = chunk.toString();
const lines = dataStr.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.trim() === '') {
continue;
}
// 这里是为了避免最后一行done给json.parse带来转换报错,遇到问题直接跳过,下一次while循环done会是true,直接break
if (line === 'data: [DONE]') continue;
const data = JSON.parse(line.replace('data: ', ''));
contentt += data.choices[0].delta.content || '';
onUpdate(contentt);
}
}
};
readStream();
}
} catch (error) {
console.error('Error fetching data:', error);
onError('Error fetching data. Please try again later.');
}
}
});
然后是修改提交的方法,这里我重新定义了一个request
方法用于响应用户的提交。这里主要是调用onRequest
方法,从而触发agent
的request
方法。
async function request(nextContent: string) {
if (!nextContent) return
status.value = 'pending';
content.value = ''
onRequest(nextContent)
}
效果展示
我调用的大模型是Qwen3
,下面是和大模型对话的效果截图。
组件代码
总的代码文件,目前只是刚刚实现对话,整体比较粗糙。运行只需要将所有代码保存为一个vue
文件,然后展示组件即可。记得在your-api-key
处替换为自己的api-key
。
<script setup lang="ts">
import type { AttachmentsProps, BubbleListProps, ConversationsProps, PromptsProps } from 'ant-design-x-vue'
import type { VNode } from 'vue'
import {
CloudUploadOutlined,
CommentOutlined,
EllipsisOutlined,
FireOutlined,
HeartOutlined,
PaperClipOutlined,
PlusOutlined,
ReadOutlined,
ShareAltOutlined,
SmileOutlined,
} from '@ant-design/icons-vue'
import { Badge, Button, Flex, Space, Typography, theme, Card } from 'ant-design-vue'
import {
Attachments,
Bubble,
Conversations,
Prompts,
Sender,
useXChat,
Welcome,
ThoughtChain,
useXAgent,
type ThoughtChainItem,
} from 'ant-design-x-vue'
import { computed, h, ref, watch, cloneVNode } from 'vue'
import { ElMessage } from 'element-plus'
const { token } = theme.useToken()
const styles = computed(() => {
return {
'layout': {
'width': '100%',
'min-width': '1000px',
'height': '722px',
'border-radius': `${token.value.borderRadius}px`,
'display': 'flex',
'background': `${token.value.colorBgContainer}`,
'font-family': `AlibabaPuHuiTi, ${token.value.fontFamily}, sans-serif`,
},
'menu': {
'background': `${token.value.colorBgLayout}80`,
'width': '280px',
'height': '100%',
'display': 'flex',
'flex-direction': 'column',
},
'conversations': {
'padding': '0 12px',
'flex': 1,
'overflow-y': 'auto',
},
'chat': {
'height': '100%',
'width': '100%',
'max-width': '700px',
'margin': '0 auto',
'box-sizing': 'border-box',
'display': 'flex',
'flex-direction': 'column',
'padding': `${token.value.paddingLG}px`,
'gap': '16px',
},
'messages': {
flex: 1,
},
'placeholder': {
'padding-top': '32px',
'text-align': 'left',
'flex': 1,
'display': 'flex', // 新增弹性容器
'flex-direction': 'column',
'align-items': 'stretch'
},
'sender': {
'box-shadow': token.value.boxShadow,
},
'logo': {
'display': 'flex',
'height': '72px',
'align-items': 'center',
'justify-content': 'start',
'padding': '0 24px',
'box-sizing': 'border-box',
},
'logo-img': {
width: '24px',
height: '24px',
display: 'inline-block',
},
'logo-span': {
'display': 'inline-block',
'margin': '0 8px',
'font-weight': 'bold',
'color': token.value.colorText,
'font-size': '16px',
},
'addBtn': {
background: '#1677ff0f',
border: '1px solid #1677ff34',
width: 'calc(100% - 24px)',
margin: '0 12px 24px 12px',
},
} as const
})
defineOptions({ name: 'PlaygroundIndependentSetup' })
const sleep = () => new Promise(resolve => setTimeout(resolve, 500))
function renderTitle(icon: VNode, title: string) {
return h(Space, { align: 'start' }, [icon, h('span', title)])
}
const defaultConversationsItems = [
{
key: '0',
label: '今天天气如何?',
},
]
const placeholderPromptsItems: PromptsProps['items'] = [
{
key: '1',
label: renderTitle(h(FireOutlined, { style: { color: '#FF4D4F' } }), '热门话题'),
description: '你对什么感兴趣?',
children: [
{
key: '1-1',
description: `NBA?`,
},
{
key: '1-2',
description: `什么是AGI?`,
},
{
key: '1-3',
description: `五一去哪玩?`,
},
],
},
{
key: '2',
label: renderTitle(h(ReadOutlined, { style: { color: '#1890FF' } }), '设计指引'),
description: '车站运维答疑?',
children: [
{
key: '2-1',
icon: h(HeartOutlined),
description: `今天车站总体运行如何`,
},
{
key: '2-2',
icon: h(SmileOutlined),
description: `是否有未闭合故障`,
},
{
key: '2-3',
icon: h(CommentOutlined),
description: `是否有未处理的留言`,
},
],
},
]
const senderPromptsItems: PromptsProps['items'] = [
{
key: '1',
description: '热门话题',
icon: h(FireOutlined, { style: { color: '#FF4D4F' } }),
},
{
key: '2',
description: '设计指引',
icon: h(ReadOutlined, { style: { color: '#1890FF' } }),
},
]
const roles: BubbleListProps['roles'] = {
ai: {
placement: 'start',
typing: { step: 5, interval: 20 },
styles: {
content: {
borderRadius: '16px',
},
},
},
local: {
placement: 'end',
variant: 'shadow',
},
}
// ==================== State ====================
const headerOpen = ref(false)
const content = ref('')
const conversationsItems = ref(defaultConversationsItems)
const activeKey = ref(defaultConversationsItems[0].key)
const attachedFiles = ref<AttachmentsProps['items']>([])
const agentRequestLoading = ref(false)
// ==================== Mycode ====================
// ==================== 接入通义千问 ====================
const [agent] = useXAgent({
request: async ({ message }, { onSuccess, onUpdate, onError }) => {
debugger
try {
const response = await fetch('/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-api-key'
},
body: JSON.stringify({
messages: [{ role: 'user', content: message }],
stream: true,
model: 'qwen3-32b',
}),
});
let contentt = '';
// 由于 Response 对象没有 data 属性,需要使用 response.body 来处理流式响应
const reader = response.body?.getReader();
if (reader) {
status.value = 'success';
const decoder = new TextDecoder('utf-8');
const readStream = async () => {
while (true) {
// 单次read并不是eventstream的一行,理论上应该是多行,简单地问题一次可能就全部读取完毕,复杂的问题需要读取多次
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const dataStr = chunk.toString();
const lines = dataStr.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.trim() === '') {
continue;
}
// 这里是为了避免最后一行done给json.parse带来转换报错,遇到问题直接跳过,下一次while循环done会是true,直接break
if (line === 'data: [DONE]') continue;
const data = JSON.parse(line.replace('data: ', ''));
contentt += data.choices[0].delta.content || '';
onUpdate(contentt);
}
}
};
readStream();
}
} catch (error) {
console.error('Error fetching data:', error);
onError('Error fetching data. Please try again later.');
}
}
});
async function request(nextContent: string) {
if (!nextContent) return
debugger
status.value = 'pending';
content.value = ''
onRequest(nextContent)
}
// ==================== Runtime ====================
/* const [agent] = useXAgent({
request: async ({ message }, { onSuccess }) => {
agentRequestLoading.value = true
await sleep()
agentRequestLoading.value = false
onSuccess(`Mock success return. You said: ${message}`)
},
})
*/
/**
* useXChat 的 onRequest 函数依赖于 useXAgent 返回的 agent 实例。当用户调用 onRequest 时,
* useXChat 会使用 agent 实例中的 request 函数来与后端 API 进行交互。简单来说, onRequest 是用户触发请求的入口,
* 而 request 是实际执行请求和处理响应的函数。
*/
const { onRequest, messages, setMessages } = useXChat({
agent: agent.value,
requestPlaceholder: 'Waiting...',
requestFallback: 'Mock failed return. Please try again later.'
})
watch(activeKey, () => {
if (activeKey.value !== undefined) {
setMessages([])
}
}, { immediate: true })
// ==================== Event ====================
function onSubmit(nextContent: string) {
if (!nextContent)
return
onRequest(nextContent)
content.value = ''
}
const onPromptsItemClick: PromptsProps['onItemClick'] = (info) => {
onRequest(info.data.description as string)
}
function onAddConversation() {
conversationsItems.value = [
...conversationsItems.value,
{
key: `${conversationsItems.value.length}`,
label: `新对话 ${conversationsItems.value.length}`,
},
]
activeKey.value = `${conversationsItems.value.length}`
}
const onConversationClick: ConversationsProps['onActiveChange'] = (key) => {
activeKey.value = key
ElMessage({
message: `切换到会话 ${key}`,
type: 'info',
})
}
const handleFileChange: AttachmentsProps['onChange'] = info => attachedFiles.value = info.fileList
// ==================== Nodes ====================
const placeholderNode = computed(() => h(
Space,
{ direction: "vertical", size: 16, style: styles.value.placeholder },
[
h(
Welcome,
{
variant: "borderless",
icon: "https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*s5sNRo5LjfQAAAAAAAAAAAAADgCCAQ/fmt.webp",
title: "你好,我是问答助手",
description: "本次问答由阿里巴巴通义千问Qwen-32B-Int4模型提供支持",
extra: h(Space, { style: { width: '100%' }, align: 'end' }, [h(Button, { icon: h(ShareAltOutlined) }), h(Button, { icon: h(EllipsisOutlined) })]),
contentStyle: {
'width': '100%',
'max-width': 'none',
'display': 'flex', // 新增弹性布局
'flex-direction': 'column',
'flex': '1',
}
}
),
h(
Prompts,
{
title: "你是否想问?",
items: placeholderPromptsItems,
styles: {
list: {
width: '100%',
},
item: {
flex: 1,
},
},
onItemClick: onPromptsItemClick,
}
)
]
))
const items = computed<BubbleListProps['items']>(() => {
if (messages.value.length === 0) {
return [{ content: placeholderNode, variant: 'borderless' }]
}
return messages.value.map(({ id, message, status }) => ({
key: id,
loading: status === 'pending',
role: status === 'local' ? 'local' : 'ai',
content: message,
}))
})
</script>
<template>
<div :style="styles.layout">
<div :style="styles.menu">
<!-- 🌟 Logo -->
<div :style="styles.logo">
<img src="https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original"
draggable="false" alt="logo" :style="styles['logo-img']">
<span :style="styles['logo-span']">小智</span>
</div>
<!-- 🌟 添加会话 -->
<Button type="link" :style="styles.addBtn" @click="onAddConversation">
<PlusOutlined />
新对话
</Button>
<!-- 🌟 会话管理 -->
<Conversations :items="conversationsItems" :style="styles.conversations" :active-key="activeKey"
@active-change="onConversationClick" />
</div>
<div :style="styles.chat">
<!-- 🌟 消息列表 -->
<Bubble.List :items="items" :roles="roles" :style="styles.messages" />
<!-- 🌟 提示词 -->
<Prompts :items="senderPromptsItems" @item-click="onPromptsItemClick" />
<!-- 🌟 输入框 -->
<Sender :value="content" :style="styles.sender" :loading="agentRequestLoading" @submit="request"
@change="value => content = value">
<template #prefix>
<Badge :dot="attachedFiles.length > 0 && !headerOpen">
<Button type="text" @click="() => headerOpen = !headerOpen" :disabled="status === 'pending'">
<template #icon>
<PaperClipOutlined />
</template>
</Button>
</Badge>
</template>
<template #header>
<Sender.Header title="附件" :open="headerOpen" :styles="{ content: { padding: 0 } }"
@open-change="open => headerOpen = open">
<Attachments :before-upload="() => false" :items="attachedFiles" @change="handleFileChange">
<template #placeholder="type">
<Flex v-if="type && type.type === 'inline'" align="center" justify="center" vertical gap="2">
<Typography.Text style="font-size: 30px; line-height: 1;">
<CloudUploadOutlined />
</Typography.Text>
<Typography.Title :level="5" style="margin: 0; font-size: 14px; line-height: 1.5;">
文件上传
</Typography.Title>
<Typography.Text type="secondary">
点击或拖拽文件至此处
</Typography.Text>
</Flex>
<Typography.Text v-if="type && type.type === 'drop'">
Drop file here
</Typography.Text>
</template>
</Attachments>
</Sender.Header>
</template>
</Sender>
</div>
</div>
</template>