基于UniApp实现DeepSeek AI对话:流式数据传输与实时交互技术解析
在移动应用开发中,集成AI对话功能已成为提升用户体验的重要手段。本文将详细介绍如何在UniApp中实现类似DeepSeek的AI对话界面,重点讲解流式数据传输、实时交互等核心技术。
【最后附上完整源代码】
实现效果
核心技术实现
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>
技术要点总结
-
流式传输:通过
enableChunked: true
和onChunkReceived
实现实时数据传输 - SSE协议:使用Server-Sent Events协议处理服务器推送
- 二进制处理:正确处理Uint8Array数据流转换
- 状态管理:完善的请求状态控制防止重复提交
- 用户体验:自动滚动、思考过程展示等细节优化
这种实现方式能够提供流畅的AI对话体验,适用于各种需要实时交互的AI应用场景。