借助antd-design-x-vue实现接入通义千问大语言模型的对话功能(附源码)

说在前面

现在大模型如此火热,想必你跟我也有同样的想法,实现一个自己的AI对话框,相比Dify等组件分享出来的对话框,自己实现起来可以更加灵活和适应需求。
虽然Element,Antd都发布了各自的对话框组件,我说句实话,这个理解起来真没之前那种Button,Card这些组件来的简单,下面分享我的一个小Demo

功能拆解

首先,官方帮我们实现了一个小的原型,附带了几乎所有的功能,地址如下:
ant-design-v-vue样例
相信这个页面能够帮助大家更快的理解每个组件之间的作用机制。
然后,我们需要做的就是接入我们自己的大模型服务,通过官方的框架实现AI对话。

一些理解

在接入大模型之前分享一下对于这个框架的理解,不感兴趣可以直接跳过。

  • 组件的封装考虑了很多一般人不会考虑的东西,所以相对抽象了一些
  • 实现接入大模型服务的核心在“工具”这里
    useXAgent 是智能体,负责大模型请求的发起,所以叫模型调度
    useXChat 是对话的管理,其中的onRequest关联了useXAgentrequest,当用户调用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方法,从而触发agentrequest方法。

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>
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值