普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月14日首页

基于UniApp实现DeepSeek AI对话:流式数据传输与实时交互技术解析

作者 BumBle
2025年10月14日 17:49

在移动应用开发中,集成AI对话功能已成为提升用户体验的重要手段。本文将详细介绍如何在UniApp中实现类似DeepSeek的AI对话界面,重点讲解流式数据传输、实时交互等核心技术。

【最后附上完整源代码】

实现效果

image.png

核心技术实现

1. 流式数据传输核心

流式数据传输是实现实时AI对话的关键,我们使用微信小程序的enableChunked配置来启用分块传输:

sendChats(params, isFirstTime) {
  const requestTask = wx.request({
    url: `${empInterfaceUrl}/gateway/basics/aiDialog/sendMsg`,
    timeout: 60000,
    responseType: 'text', // 必须设置为text才能处理流式数据
    method: 'POST',
    enableChunked: true, // 关键配置:启用分块传输
    header: {
      Accept: 'text/event-stream', // 接受服务器推送事件
      'Content-Type': 'application/json',
    },
    data: params,
  })
}

2. 流式数据实时处理

通过onChunkReceived监听器实时处理服务器推送的数据块:

this.chunkListener = (res) => {
  // 将二进制数据转换为文本
  const uint8Array = new Uint8Array(res.data)
  let text = String.fromCharCode.apply(null, uint8Array)
  text = decodeURIComponent(escape(text))
  
  // 解析SSE格式数据
  const messages = text.split('data:')
  
  messages.forEach(message => {
    if (!message.trim()) return
    
    const data = JSON.parse(message)
    
    // 处理AI回复数据
    if (data.data && data.data.answer) {
      const lastChat = this.chatArr[this.chatArr.length - 1]
      
      // 分离思考过程和实际回复
      const cleanedAnswer = data.data.answer.replace(/<think>[\s\S]*?<\/think>/g, '')
      const thinkContent = data.data.answer.match(/<think>([\s\S]*?)<\/think>/g)
        ?.map(tag => tag.replace(/<\/?think>/g, ''))
        ?.join(' ')
      
      // 实时更新UI
      if (lastChat && lastChat.type === 'robot' && cleanedAnswer) {
        lastChat.content = cleanedAnswer
        this.scrollToLower() // 自动滚动到底部
      }
    }
  })
}

// 注册监听器
requestTask.onChunkReceived(this.chunkListener)

3. 双模式参数构建

支持普通对话和产品话术两种模式:

getParams(item, content) {
  let data = {
    rootShopId: this.empShopInfo.rootShop,
    shopId: this.empShopInfo.shopId
  }
  
  if (this.sessionId) data.sessionId = this.sessionId
  
  if (this.type === 'product') {
    // 产品模式参数
    data = {
      ...data,
      msgType: 'prod',
      prodMsgType: this.sessionId ? item.value : '1',
      msg: this.productInfo.itemTitle,
      prodId: this.productInfo.itemId,
    }
  } else {
    // 普通对话模式参数
    data = {
      ...data,
      msgType: 'ai',
      msg: content || this.content
    }
  }
  return data
}

4. 消息生成控制

防止重复请求和实现重新生成功能:

generate(item, index) {
  // 防止重复请求
  if (this.isListening) {
    let msg = this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试'
    this.$alert(msg)
    return
  }
  
  let content
  // 重新生成时从历史消息获取原始提问
  if (index !== undefined) {
    for (let i = index - 1; i >= 0; i--) {
      if (this.chatArr[i].type === 'self') {
        content = this.chatArr[i].content
        break
      }
    }
  }
  
  // 添加用户消息到对话列表
  this.chatArr.push({
    type: 'self',
    content
  })
}

5. 自动滚动机制

确保新消息始终可见:

scrollToLower() {
  this.scrollIntoView = ''
  // 异步确保滚动生效
  setTimeout(() => {
    this.scrollIntoView = 'lower'
  }, 250)
}

完整源代码

以下是完整的组件代码,包含详细注释:

<template>
  <view class="ai">
    <scroll-view class="ai-scroll"  :scroll-into-view="scrollIntoView" scroll-y scroll-with-animation>
      <view class="ai-tips flex-c-c">
        <view class="ai-tips-content">{{ type === 'product' ? '请在下面点击选择您想生成的内容' : '请在下面输入框输入您想生成的内容' }}</view>
      </view>
      <view style="padding: 0 20rpx ">
        <view class="ai-product" v-if="type === 'product'">
          <image :src="productInfo.miniMainImage || productInfo.mainImage" class="ai-product-img" mode="aspectFill" />
          <view class="ai-product-info">
            <view>{{ productInfo.itemTitle }}</view>
            <view class="ai-product-info-price">¥{{ productInfo.spePrice }}</view>
          </view>
        </view>
      </view>
      <view class="ai-chat" v-for="(item, index) in chatArr" :key="index">
        <view class="ai-chat-item self" v-if="item.type === 'self'">
          <view class="ai-chat-content">{{ item.content}}</view>
          <image class="ai-chat-avatar" :src="empUserInfo.avatarUrl || DEFAULT_AVATAR_URL"></image>
        </view>
        <view class="ai-chat-item robot" v-if="item.type === 'robot'">
          <image class="ai-chat-avatar" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_avatar.png`"></image>
          <view class="ai-chat-content">
            <view class="ai-chat-content-box flex-c content-think" @click="switchExpand(item)">
              {{ item.isListening ? '正在思考中...' : '已推理' }}
              <MDIcon :name="item.expand ? 'arrowUp' : 'arrowDown'" color="#919099" left="8" />
            </view>
            <text class="ai-chat-content-box  content-think" v-if="item.expand">{{ item.think }}</text>
            <text class="ai-chat-content-box">{{ item.content }}</text>
            <view class="ai-chat-opt flex-c">
              <template v-if="item.isListening">
                <view class="ai-chat-opt-btn pause-btn flex-c-c" hover-class="h-c" @click="pauseAnswer(index)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_pause.png`"></image>
                  暂停回答
                </view>
              </template>
              <template v-else>
                <view class="ai-chat-opt-btn flex-c-c" hover-class="h-c" @click="generate(item, index)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_reset.png`"></image>
                  重新生成
                </view>
                <view class="ai-chat-opt-btn flex-c-c" hover-class="h-c" @click="copyAnswer(item.content)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_copy.png`"></image>
                  复制回答
                </view>
              </template>
            </view>
          </view>
        </view>
      </view>
      <view id="lower" class="lower"></view>
    </scroll-view>
    <view class="ai-footer">
      <view class="ai-footer-buttons flex-c" v-if="type === 'product'">
        <view class="ai-footer-buttons-btn flex-c-c" v-for="x in footerBtnList" :key="x.value" hover-class="h-c" @click="generate(x)">
          {{ x.label }}
        </view>
      </view>
      <template v-else>
        <view class="ai-keyboard">
          <textarea class="ai-keyboard-inp" v-model="content" cursor-spacing="30" maxlength="-1" placeholder="请输入相关产品信息" @confirm="generate()"></textarea>
        </view>
        <view class="ai-send flex-c-c" hover-class="h-c" @click="generate()">
          <image class="ai-send-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_send.png`"></image>
          开始生成
        </view>
      </template>
    </view>
  </view>
</template>

<script>
import { empInterfaceUrl } from '@/config'

export default {
  data() {
    return {
      content: '', // 内容
      type: 'normal', // 类型:normal-普通,product-产品
      productInfo: {},
      footerBtnList: [
        { label: '首次分享话术', value: '1' },
        { label: '破冰话术', value: '2' },
        { label: '产品介绍', value: '3' },
        { label: '产品优点', value: '4' }
      ],
      requestTask: null,
      sessionId: '',
      isListening: false, // 添加状态变量
      chatArr: [],
      scrollIntoView: 'lower',
      chunkListener: null
    }
  },
  methods: {
    scrollToLower() {
      this.scrollIntoView = ''
      setTimeout(() => {
        this.scrollIntoView = 'lower'
      }, 250)
    },
    switchExpand(item) {
      item.expand = !item.expand
      this.$forceUpdate()
    },
    copyAnswer(content) {
      uni.setClipboardData({
        data: content,
        success: () => {
          uni.showToast({ title: '复制成功', icon: 'none' })
        }
      })
    },
    getParams(item, content) {
      let data = {
        rootShopId: this.empShopInfo.rootShop,
        shopId: this.empShopInfo.shopId
      }
      if (this.sessionId) data.sessionId = this.sessionId
      if (this.type === 'product') {
        data = {
          ...data,
          msgType: 'prod',
          prodMsgType: this.sessionId ? item.value : '1',
          msg: this.productInfo.itemTitle,
          prodId: this.productInfo.itemId,
        }
        // 如果是重新生成,获取上一个的提问内容的value
        if (content) {
          const footerValue = this.footerBtnList.find(x => x.label === content).value
          data.prodMsgType = footerValue
        }
      } else {
        data = {
          ...data,
          msgType: 'ai',
          msg: content || this.content // 第一次:'' , ai模式:1.this.content 2.重新生成content
        }
      }
      return data
    },
    // 开始生成
    // 第一个参数为按钮信息(product模式),第二个参数为重新生成需要的index
    generate(item, index) {
      if (this.isListening) {
        let msg = this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试'
        this.$alert(msg)
        return
      }
      if (this.type === 'normal' && !this.content.trim() && !index) {
        return uni.showToast({ title: '请输入相关产品信息', icon: 'none' })
      }
      let content
      // 如果是重新生成,获取上一个的提问内容
      if (index !== undefined) {
        for (let i = index - 1; i >= 0; i--) {
          if (this.chatArr[i].type === 'self') {
            content = this.chatArr[i].content
            break
          }
        }
      } else {
        content = this.type === 'product' ? item.label : this.content
      }
      this.chatArr.push({
        type: 'self',
        content
      })
      this.scrollToLower()
      const params = this.getParams(item, content)
      this.content = ''
      this.isListening = true
      this.sendChats(params)
    },

    sendChats(params, isFirstTime) {
      let chatIndex // 获取新添加的robot消息的索引
      // 取消之前的请求
      if (this.requestTask) {
        this.requestTask.abort()
        this.requestTask = null
      }
      if (!isFirstTime) {
        this.chatArr.push({
          type: 'robot',
          think:'',
          expand: false,
          content: '',
          isListening: true
        })
        chatIndex = this.chatArr.length - 1
      }
      this.scrollToLower()
      const requestTask = wx.request({
        url: `${empInterfaceUrl}/gateway/basics/aiDialog/sendMsg`,
        timeout: 60000,
        responseType: 'text',
        method: 'POST',
        enableChunked: true,
        header: {
          Accept: 'text/event-stream',
          'Content-Type': 'application/json',
          'root-shop-id': this.empShopInfo.rootShop,
          Authorization: this.$store.getters.empBaseInfo.token
        },
        data: params,
        fail: () => {
          this.isListening = false
          if (chatIndex !== undefined) {
            this.chatArr[chatIndex].isListening = false
          }
        }
      })
      // 移除之前的监听器
      if (this.chunkListener && this.requestTask) {
        this.requestTask.offChunkReceived(this.chunkListener)
      }
      // 添加新的监听器
      this.chunkListener = (res) => {
        if (!this.isListening) {
          requestTask.abort()
          return
        }
        const uint8Array = new Uint8Array(res.data)
        let text = String.fromCharCode.apply(null, uint8Array)
        text = decodeURIComponent(escape(text))
        const messages = text.split('data:')
        messages.forEach(message => {
          if (!message.trim()) {
            return
          }
          const data = JSON.parse(message)
          if (data.data === true) {
            this.pauseAnswer(chatIndex, isFirstTime)
            return
          }
          if (data.data && data.data.session_id && isFirstTime) {
            this.sessionId = data.data.session_id
            this.isListening = false
            return
          }
          if (data.data && data.data.answer) {
            const lastChat = this.chatArr[this.chatArr.length - 1]
            const cleanedAnswer = data.data.answer.replace(/<think>[\s\S]*?<\/think>/g, '')
            const thinkContent = data.data.answer.match(/<think>([\s\S]*?)<\/think>/g)?.map(tag => tag.replace(/<\/?think>/g, ''))?.join(' ')
            if (lastChat && lastChat.type === 'robot' && cleanedAnswer) {
              lastChat.content = cleanedAnswer
              this.scrollToLower()
            }
            if (thinkContent) {
              lastChat.think = thinkContent
              this.scrollToLower()
            }
          }
        })
      }
      requestTask.onChunkReceived(this.chunkListener)
      this.requestTask = requestTask
    },
    pauseAnswer(index, isFirstTime) {
      if (this.requestTask) {
        this.requestTask.abort()
        this.requestTask.offChunkReceived(this.chunkListener)
        this.requestTask = null
      }
      this.isListening = false
      if (!isFirstTime) {
        this.chatArr[index].isListening = false
      }
    },
    getAiSessionId() {
      const params = this.getParams()
      this.isListening = true
      this.sendChats(params, true)
    }
  },
  onLoad(options) {
    this.type = options.type || 'normal'
    this.$store.dispatch('checkLoginHandle').then(() => {
      if (options.type === 'product') {
        this.productInfo = uni.getStorageSync('productInfo')
        uni.removeStorageSync('subShopInfo')
      }
      this.getAiSessionId()
    })
  },
  beforeDestroy() {
    // 移除之前的监听器
    if (this.requestTask) {
      this.requestTask.abort()
      if (this.chunkListener) {
        this.requestTask.offChunkReceived(this.chunkListener)
      }
      this.requestTask = null
    }
  }
}
</script>

<style lang="scss">
page {
  background: #f5f5f5;
}
.ai {
  padding-top: 20rpx;
  &-scroll {
    height: calc(100vh - 120rpx);
    overflow: auto;
  }
  &-tips {
    &-content {
      padding: 0 8rpx;
      height: 36rpx;
      background: #eeeeee;
      font-size: 24rpx;
      color: #999999;
    }
  }
  &-product {
    padding: 20rpx;
    background: #fff;
    border-radius: 8rpx;
    margin: 24rpx 0;
    display: flex;
    &-img {
      flex-shrink: 0;
      width: 120rpx;
      height: 120rpx;
      background: #EEEEEE;
      border-radius: 4rpx 4rpx 4rpx 4rpx;
      margin-right: 16rpx;
    }
    &-info {
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      &-price {
        font-weight: 700;
        color: #FF451C;
      }
    }
  }
  &-chat {
    padding: 0 20rpx;
    &-item {
      margin-top: 40rpx;
      display: flex;
      &.self {
        .ai-chat-content {
          background: $uni-base-color;
          color: #ffffff;
          margin-right: 10rpx;
          margin-left: 0rpx;
        }
      }
    }
    &-content {
      background: #fff;
      border-radius: 14rpx;
      padding:27rpx 20rpx;
      font-size: 28rpx;
      color: #333;
      line-height: 33rpx;
      word-break: break-all;
      flex: 1;
      margin-left: 10rpx;
      .content-think {
        color: #919099;
        margin-bottom: 8rpx;
      }
    }
    &-avatar {
      width: 88rpx;
      height: 88rpx;
      border-radius: 14rpx;
    }
    &-opt {
      justify-content: flex-end;
      margin-top: 40rpx;
      border-top: 1px solid #eeeeee;
      padding-top: 20rpx;
      &-btn {
        padding: 0 16rpx;
        height: 64rpx;
        border-radius: 8rpx;
        border: 1px solid $uni-base-color;
        font-size: 24rpx;
        color: $uni-base-color;
        &:last-child {
          background: $uni-base-color;
          margin-left: 20rpx;
          color: #fff;
        }
        &.pause-btn {
          border: 2rpx solid $uni-base-color;
          color: $uni-base-color;
          background: none;
        }
      }
      &-icon {
        width: 32rpx;
        height: 32rpx;
        margin-right: 8rpx;
      }
    }
  }
  &-footer {
    min-height: 120rpx;
    position: fixed;
    bottom: 0;
    background: #fff;
    left: 0;
    right: 0;
    z-index: 1;
    padding: 20rpx;
    &-buttons {
      &-btn {
        width: 163rpx;
        height: 64rpx;
        font-size: 24rpx;
        color: #FFFFFF;
        line-height: 28rpx;
        background: $uni-base-color;
        border-radius: 8rpx 8rpx 8rpx 8rpx;
        &:not(:last-child) {
          margin-right: 20rpx;
        }
      }
    }
  }
  &-keyboard {
    background: #f5f5f5;
    border-radius: 8rpx;
    padding: 20rpx;
    &-inp {
      font-size: 28rpx;
      height: 146rpx;
      box-sizing: border-box;
      display: block;
      width: 100%;
    }
  }
  &-send {
    height: 72rpx;
    background: $uni-base-color;
    border-radius: 8rpx;
    margin-top: 18rpx;
    color: #ffffff;
    &-icon {
      width: 36rpx;
      height: 36rpx;
      margin-right: 8px;
    }
  }
  .lower {
    height: 350rpx;
    width: 750rpx;
  }
}
</style>

技术要点总结

  1. 流式传输:通过enableChunked: trueonChunkReceived实现实时数据传输
  2. SSE协议:使用Server-Sent Events协议处理服务器推送
  3. 二进制处理:正确处理Uint8Array数据流转换
  4. 状态管理:完善的请求状态控制防止重复提交
  5. 用户体验:自动滚动、思考过程展示等细节优化

这种实现方式能够提供流畅的AI对话体验,适用于各种需要实时交互的AI应用场景。

昨天以前首页
❌
❌