在移动应用开发中,集成AI对话功能已成为提升用户体验的重要手段。本文将详细介绍如何在UniApp中实现类似DeepSeek的AI对话界面,重点讲解流式数据传输、实时交互等核心技术。
【最后附上完整源代码】
实现效果
主要逻辑流程简析
为了帮助大家理解核心流程,以下是对该页面代码逻辑的简化说明:
一、页面结构逻辑
-
两种模式:
- 产品模式:展示产品信息,提供固定话术按钮(首次分享/破冰话术/产品介绍/产品优点)
- 普通模式:显示输入框,用户可自由输入内容
-
聊天区域:
- 用户提问显示在右侧(
type="self") - AI回答显示在左侧(
type="robot") - AI回答包含:
- 思考过程(可展开/收起)
- 最终回答内容
- 操作按钮(重新生成/复制/暂停)
- 用户提问显示在右侧(
二、核心交互逻辑
1. 初始化阶段
- 根据页面参数判断模式(
normal/product) - 获取会话ID:
发送一次空请求到AI接口,获取本次对话的sessionId,用于保持上下文关联
2. 用户提问流程
用户点击按钮/输入内容
→ 生成请求参数(包含会话ID/店铺信息/问题内容)
→ 显示用户提问到聊天区
→ 显示AI"思考中"状态
→ 滚动到底部
3. AI回答处理
-
流式接收:使用分块传输(chunked)实时获取AI回复
-
内容解析:
- 提取
<think>标签内的思考过程 - 提取最终回答内容
- 实时更新到聊天界面
- 提取
-
特殊处理:
- 收到结束标志
data: true时停止接收 - 支持中途暂停回答
- 收到结束标志
4. 功能操作
- 重新生成:基于相同问题重新请求AI
- 复制回答:将AI回复内容复制到剪贴板
- 暂停回答:中止当前AI回答请求
三、技术要点
- 会话保持:通过
sessionId维持多轮对话上下文 - 流式传输:使用
enableChunked实现AI回答的实时显示 - 滚动控制:自动滚动到最新消息位置
- 请求管理:支持中止进行中的AI请求
四、数据流向
用户输入 → 组装参数 → AI接口 → 流式响应 → 解析内容 → 更新UI
这样设计的优势是:
- 产品模式标准化话术生成
- 普通模式支持自由对话
- 实时显示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应用场景。