uniapp AI聊天应用技术解析:实现流畅的Streaming聊天体验(基础版本)

本文将深入解析一个基于uni-app的AI聊天应用,重点讲解其如何通过Streaming技术实现流畅的聊天体验。这个应用不仅支持基本的文本对话,还实现了流式响应、实时暂停、内容复制等高级功能。

本文用的AI模型是联通,博主还有一篇业务逻辑更复杂的(包含思考内容、多种聊天模式)AI模型为DeepSeek的博客快速前往

实现效果

技术亮点

  1. 流式数据处理:通过SSE实现实时数据流接收,提升用户体验
  2. 内存优化:及时清理请求和监听器,避免内存泄漏
  3. 用户体验:自动滚动、加载状态、暂停功能、复制内容等细节处理
  4. 错误处理:完善的异常捕获和错误提示机制
  5. 响应式设计:智能输入框根据内容动态调整高度

1. 核心流程解析

1.1 整体交互流程

  • 用户输入:用户在底部输入框输入问题,点击发送或按回车
  • 消息展示:用户消息立即显示在聊天区域,AI回复区域显示"加载中..."
  • 流式请求:向服务器发送SSE(Server-Sent Events)请求,接收分块数据
  • 实时渲染:逐步接收并显示AI回复内容
  • 交互控制:支持暂停回答、复制内容等操作

1.2 数据流处理

复制代码
用户输入 → 本地显示 → 创建SSE连接 → 分块接收数据 → 实时渲染 → 完成/暂停

1.3 关键状态管理

  • isListening: 控制是否正在接收AI回复
  • sessionId: 维持对话会话
  • chatArr: 存储所有聊天记录
  • requestTask: 管理HTTP请求,支持中止

2. 核心代码详解

2.1 Streaming数据接收处理

javascript 复制代码
// 分块数据接收处理
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
    
    try {
      const data = JSON.parse(message)
      
      // 结束标识处理
      if (data.execute.finished === true) {
        this.pauseAnswer(chatIndex)
        return
      }
      
      // 实时内容拼接
      if (data.result && data.result.richList && data.result.richList.length) {
        const lastChat = this.chatArr[this.chatArr.length - 1]
        const answer = data.result.richList.map(item => item.text).join('')
        
        if (lastChat && lastChat.type === 'robot' && answer) {
          lastChat.content += answer
          this.scrollToLower() // 自动滚动到底部
        }
      }
    } catch (error) {
      console.error('JSON解析错误:', error)
    }
  })
}

2.2 请求管理与暂停功能

javascript 复制代码
// 暂停回答功能
pauseAnswer(index) {
  if (this.requestTask) {
    this.requestTask.abort() // 中止请求
    this.requestTask.offChunkReceived(this.chunkListener) // 移除监听器
    this.requestTask = null
  }
  this.isListening = false
  this.chatArr[index].isListening = false // 更新UI状态
}

2.3 智能输入框控制

javascript 复制代码
// 动态调整输入框高度
computed: {
  getExpandStyle() {
    if (this.isExpand) {
      return 'height: 85vh;' // 展开模式
    } else {
      return 'max-height: 170rpx;' // 普通模式
    }
  }
},
methods: {
  setShowExpand(e) {
    this.showExpand = e.detail?.lineCount >= 4 || false
  }
}

2.4 消息发送流程

javascript 复制代码
generate() {
  if (this.isListening) {
    this.$alert(this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试')
    return
  }
  
  if (!this.content.trim()) {
    return uni.showToast({ title: '请描述需求', icon: 'none' })
  }
  
  // 添加用户消息到聊天记录
  this.chatArr.push({
    type: 'self',
    content: this.content
  })
  
  this.scrollToLower()
  this.content = ''
  this.isListening = true
  
  // 发送请求
  this.sendChats({
    content: this.content,
    sessionId: this.sessionId
  })
}

3. 完整代码实现

javascript 复制代码
<template>
  <view class="ai">
    <scroll-view class="ai-scroll" :scroll-into-view="scrollIntoView" scroll-y scroll-with-animation>
      <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" style="max-width: 520rpx">{{ item.content }}</view>
        </view>
        <view class="ai-chat-item robot" v-if="item.type === 'robot'">
          <view class="ai-chat-content" style="width: 520rpx">
            <view class="ai-chat-content-box flex-c content-think" v-if="!item.content && item.isListening">加载中...</view>
            <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="copyAnswer(item)">复制</view>
              </template>
            </view>
          </view>
        </view>
      </view>
      <view id="lower" class="lower"></view>
    </scroll-view>
    <view class="ai-footer flex-c">
      <view class="ai-footer-keyboard">
        <view class="keyboard-clear" hover-class="h-c" @click="content = ''" v-if="isExpand">清空</view>
        <view class="flex-c">
        <textarea
          v-model="content"
          class="keyboard-inp"
          :style="getExpandStyle"
          :auto-height="!isExpand"
          cursor-spacing="30"
          :show-confirm-bar="false"
          maxlength="-1"
          placeholder="请描述您的需求"
          placeholder-style="font-size: 28rpx; color: #CCCCCC; line-height: 45rpx;"
          @confirm="generate()"
          @linechange="setShowExpand"
        ></textarea>
        <image class="btns-icon expand" :src="`${mdFileBaseUrl}/stand/emp/intelligent/${ !isExpand ? 'expand' : 'collapse' }_icon.png`" @click="toggleExpand()" v-if="showExpand || isExpand"></image>
        <image class="btns-icon send" :src="`${mdFileBaseUrl}/stand/emp/intelligent/send_${ !content.trim() ? 'disabled' : 'primary' }.png`" @click="generate()"></image>
        </view>
      </view>
    </view>
  </view>
</template>
<script>
import { empInterfaceUrl } from '@/config'
import { getUuid } from '@/common/util'

export default {
  data() {
    return {
      content: '', // 内容
      requestTask: null,
      sessionId: '',
      isListening: false, // 添加状态变量
      chatArr: [],
      scrollIntoView: 'lower',
      chunkListener: null,
      filterInfo: {},
      showExpand: false,
      isExpand: false
    }
  },
  computed: {
    getExpandStyle() {
      if (this.isExpand) {
        return 'height: 85vh;'
      } else {
        return 'max-height: 170rpx;'
      }
    }
  },
  methods: {
    toggleExpand() {
      this.isExpand = !this.isExpand
    },
    setShowExpand(e) {
      this.showExpand = e.detail?.lineCount >= 4 || false
    },
    // 自动滚动到底部
    scrollToLower() {
      this.scrollIntoView = ''
      setTimeout(() => {
        this.scrollIntoView = 'lower'
      }, 250)
    },
    copyAnswer(item) {
      uni.setClipboardData({
        data: item.content,
        success: () => {
          uni.hideLoading()
          uni.showModal({
            title: '提示',
            content: '话术已复制到粘贴板,请进入好友聊天对话框,粘贴发送',
            showCancel: false,
            confirmText: '我知道了'
          })
        }
      })
    },
    // 发送(开始生成)
    generate() {
      if (this.isListening) {
        let msg = this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试'
        this.$alert(msg)
        return
      }
      if (!this.content.trim()) {
        return uni.showToast({ title: '请描述需求', icon: 'none' })
      }
      this.chatArr.push({
        type: 'self',
        content: this.content
      })
      this.scrollToLower()
      this.content = ''
      this.isListening = true
      this.sendChats({
        content: this.content,
        sessionId: this.sessionId
      })
    },
    sendChats(params) {
      let chatIndex // 获取新添加的robot消息的索引
      // 取消之前的请求
      if (this.requestTask) {
        this.requestTask.abort()
        this.requestTask = null
      }
      this.chatArr.push({
        type: 'robot',
        content: '',
        isListening: true
      })
      chatIndex = this.chatArr.length - 1
      this.scrollToLower()
      // 发起请求
      const requestTask = wx.request({
        url: `${empInterfaceUrl}/gateway/helper/emp/aiSaleTechniques/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)
        // 将Uint8Array转换为字符串
        let text = String.fromCharCode.apply(null, uint8Array)
        // 处理中文乱码问题
        text = decodeURIComponent(escape(text))
        // 按data:分割消息
        const messages = text.split('data:')
        messages.forEach(message => {
          if (!message.trim()) {
            return
          }
          // 流返回的数据结构有可能不是完整的JSON,需要捕获解析错误
          try {
            // 解析JSON数据
            const data = JSON.parse(message)
            // 结束标识(这里的结束标识是 data.execute.finished === true)
            if (data.execute.finished === true) {
              this.pauseAnswer(chatIndex)
              return
            }
            // 追加回答内容(回答内容的数据在result.richList数组中的每个text)
            if (data.result && data.result.richList && data.result.richList.length) {
              // 获取最后一条聊天记录
              const lastChat = this.chatArr[this.chatArr.length - 1]
              // 拼接回答内容
              const answer = data.result.richList.map(item => item.text).join('')
              // 追加内容到最后一条聊天记录
              if (lastChat && lastChat.type === 'robot' && answer) {
                lastChat.content += answer
                this.scrollToLower()
              }
            }
          } catch (error) {
            console.error('JSON解析错误:', error)
            console.error('解析失败的数据:', message)
          }
        })
      }
      // 绑定监听器
      requestTask.onChunkReceived(this.chunkListener)
      this.requestTask = requestTask
    },
    // 暂停回答
    pauseAnswer(index) {
      if (this.requestTask) {
        // 中止请求
        this.requestTask.abort()
        // 移除监听器
        this.requestTask.offChunkReceived(this.chunkListener)
        this.requestTask = null
      }
      this.isListening = false
      // 更新对应的聊天记录状态
      this.chatArr[index].isListening = false
    }
  },
  onLoad(options) {
    this.$store.dispatch('checkLoginHandle').then(() => {
      // 每次进入页面都生成一个新的sessionId(在这个需求中要求前端生成30位的随机数即可)
      this.sessionId = getUuid().substring(0, 30)
    })
  },
  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;
  }
  &-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: auto;
        }
      }
      &.robot {
        .ai-chat-content {
          margin-right: auto;
        }
      }
    }
    &-content {
      background: #fff;
      border-radius: 14rpx;
      padding: 27rpx 20rpx;
      font-size: 28rpx;
      color: #666666;
      line-height: 33rpx;
      word-break: break-all;
      margin-left: 10rpx;
      .content-think {
        color: #919099;
        margin-bottom: 8rpx;
      }
    }
    &-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: 2rpx solid #cccccc;
        font-size: 24rpx;
        color: #666666;
        min-width: 96rpx;
        &:last-child {
          background: rgba(56, 116, 246, 0.1);
          margin-left: 20rpx;
          color: #3874f6;
          border: none;
        }
        &.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;
    &-keyboard {
      background: #F1F1F1;
      border-radius: 8rpx 8rpx 8rpx 8rpx;
      padding: 20rpx 20rpx 20rpx 40rpx;
      width: 100%;
      position: relative;
      .keyboard-inp {
        box-sizing: border-box;
        display: block;
        width: 100%;
        font-size: 28rpx;
        color: #666666;
        line-height: 45rpx;
        overflow: auto;
        padding-right: 90rpx;
      }
      .btns-icon {
        width: 45rpx;
        height: 45rpx;
        position: absolute;
        right: 20rpx;
        &.expand {
          top: 20rpx;
        }
        &.send {
          bottom: 20rpx;
        }
      }
      .keyboard-clear {
        font-size: 28rpx;
        color: #3874F6;
        line-height: 45rpx;
        margin-bottom: 40rpx;
        display: inline-block;
      }
    }
  }
  .lower {
    height: 350rpx;
    width: 750rpx;
  }
}
</style>
相关推荐
搞个锤子哟4 小时前
vant4的van-pull-refresh里的列表不在顶部时下拉也会触发刷新的问题
前端
jnpfsoft4 小时前
低代码视图真分页实操:API/SQL 接口配置 + 查询字段避坑,数据加载不卡顿
前端·低代码
HHHHHY4 小时前
使用阿里lowcode,封装SearchDropdown 搜索下拉组件
前端·react.js
前端付豪4 小时前
万事从 todolist 开始
前端·vue.js·前端框架
小胖霞4 小时前
从零开始:在阿里云 Ubuntu 服务器部署 Node+Express 接口(基于公司 GitLab)
前端·后端
2501_915921434 小时前
运营日志驱动,在 iOS 26 上掌握 App 日志管理实践
android·macos·ios·小程序·uni-app·cocoa·iphone
A_Bin4 小时前
前端工程化之【包管理器】
前端
小肚肚肚肚肚哦4 小时前
CSS 伪类函数 :where 简介
前端·css
Nick56834 小时前
Swift -- 第三方登录之微信登录 源码分享
前端