本文将深入解析一个基于uni-app的AI聊天应用,重点讲解其如何通过Streaming技术实现流畅的聊天体验。这个应用不仅支持基本的文本对话,还实现了流式响应、实时暂停、内容复制等高级功能。
本文用的AI模型是联通,博主还有一篇业务逻辑更复杂的(包含思考内容、多种聊天模式)AI模型为DeepSeek的博客快速前往
实现效果

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