我是用的uniapp开发的app项目,代码如下:<route lang="json5" type="home">
{
layout: 'default',
style: {
navigationBarTitleText: 'AI聊天',
navigationStyle: 'custom',
disableScroll: true,
'app-plus': {
bounce: 'none',
},
},
}
</route>
<template>
<PageLayout :navTitle="navTitle" type="popup" :navBgTransparent="true" :navLeftArrow="false" :enableDropdown="true"
:arrowIcon="arrowIcon" @navBack="drawerShow = true" @navRight="handleNewConversation"
@dropdownClick="handleDropdownClick">
<template #navLeft>
<image :src="moreList" style="width: 24px; height: 24px;" />
</template>
<template #navRight>
<image :src="addConv" style="width: 24px; height: 24px;" />
</template>
<!-- 自定义下拉框内容 -->
<!-- <template #dropdown>
<view class="dropdown-content">
<text>知识库</text>
</view>
</template> -->
<!-- 页面内容 -->
<view class="wrap">
<view v-if="isAIAnswering" class="stop-answer-btn" @click="handleStopAnswer">
<text>停止回答</text>
</view>
<z-paging ref="paging" v-model="dataList" :fixed="false" :auto="false" :paging-enabled="false"
use-chat-record-mode use-virtual-list cell-height-mode="dynamic" safe-area-inset-bottom
bottom-bg-color="#e5e5e5">
<!-- 自定义空数据视图 -->
<template #empty>
<view class="empty-container">
<view>
<image :src="logo" class="empty-logo" mode="aspectFit" />
</view>
<view class="empty-title">欢迎使用水利安全生产知识库</view>
<view class="empty-subtitle">我能理解人类语言、生成内容,是你的问答智能助手。</view>
</view>
</template>
<template #cell="{ item }">
<view style="transform: scaleY(-1)">
<ai-chat-item :item="item" :isAnswering="isAIAnswering && item.sendTimeId === currentAIMessageId"
@copy="handleCopy" @regenerate="handleRegenerate" />
</view>
</template>
<template #bottom>
<ai-chat-input-bar ref="inputBar" @send="doSend" @sendImage="handleImage" />
</template>
</z-paging>
</view>
<!-- 左侧弹出框组件 -->
<ai-chat-history v-model="drawerShow" :conversationList="conversationList" :currentConversationId="conversationId"
@select="handleSelectConversation" />
</PageLayout>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { onLaunch, onShow, onHide, onLoad, onReady } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user'
import { http } from '@/utils/http'
import { useToast } from 'wot-design-uni'
import { useParamsStore } from '@/store/page-params'
import { formatDate } from '@/common/uitls'
import aiChatInputBar from './components/AiChatInputBar.vue'
import aiChatItem from './components/AiChatItem.vue'
import aiChatHistory from './components/AiChatHistory.vue'
import logo from '@/static/logo.png'
import moreList from '@/static/more-list.png';
import addConv from '@/static/add-conv.png';
import arrowIcon from '@/static/arrow-right.png'
import qs from 'qs'
import signMd5Utils from '@/utils/signMd5Utils'
// #ifdef APP-PLUS
import { EventSourcePolyfill } from 'event-source-polyfill'
// #endif
defineOptions({
name: 'aiChat',
options: {
styleIsolation: 'apply-shared',
},
})
// API 接口定义
const api = {
sendChat: '/airag/chat/send',
getConversations: '/airag/chat/conversations',
getMessages: '/airag/chat/messages',
stopChat: '/airag/chat/stop',
uploadUrl: '/sys/common/upload',
}
const toast = useToast()
const userStore = useUserStore()
const paramsStore = useParamsStore()
const paging = ref(null)
const inputBar = ref(null)
const navTitle = ref('新对话')
const dataList = ref([])
const conversationId = ref('')
const appId = ref('1980557694009438210')
const requestId = ref('')
const drawerShow = ref(false)
const conversationList = ref([])
let eventSource = null // EventSource 实例
let currentAIMessageId = ref('') // 当前 AI 消息的 ID
let currentAIMessage = ref('') // 累积的 AI 消息内容
// 添加状态变量
const isAIAnswering = ref(false)
const handleDropdownClick = () => {
console.log('下拉框被点击')
// 处理下拉框点击事件
}
// 页面初始化
const init = async () => {
const localData = paramsStore.getPageParams('aiChat')
const params = localData?.data
if (params?.appId) {
appId.value = params.appId
}
if (params?.title) {
navTitle.value = params.title
}
await loadConversation()
// 如果没有会话ID,手动触发空状态
if (!conversationId.value) {
paging.value?.complete([])
}
}
// 加载会话列表
const loadConversation = async () => {
try {
// 添加您需要的参数
const params = {
appId: appId.value,
// 其他参数...
}
const res = await http.get(api.getConversations, params)
if (res.success && res.result?.length) {
conversationList.value = res.result || [
{
"id": "2c9180839a09dce2019a0ef293460037",
"title": "消防安全的",
"messages": null,
"app": null,
"createTime": "2025-10-24 09:43:16"
},
{
"id": "237A8A0151",
"title": "啥是电影蒙发更广泛的",
"messages": null,
"app": null,
"createTime": "2025-10-23 14:53:00"
}
]
conversationId.value = res.result[0].id
}
} catch (e) {
console.error('加载会话失败', e)
toast.error('加载会话失败')
}
}
// 加载全部消息(不分页)
const loadAllMessages = async () => {
if (!conversationId.value) {
dataList.value = []
return
}
try {
const res = await http.get(`${api.getMessages}/${conversationId.value}`)
if (res.success) {
dataList.value = analysis(res.result || [])
} else {
dataList.value = []
toast.error(res.message || '加载消息失败')
}
} catch (e) {
console.error('加载消息失败', e)
dataList.value = []
toast.error('加载消息失败')
}
}
// 数据分析处理
const analysis = (data: any[]) => {
return data.map((item) => {
const time = formatDate(item.sendTime || new Date(), 'yyyy-MM-dd hh:mm:ss')
const timeStr = String(time || '')
const id = timeStr.replace(/:/g, '').replace(/-/g, '').replace(' ', '')
return {
...item,
sendTimeId: id,
msgFrom: item.role === 'user' ? userStore.userInfo.userid : 'ai',
fromUserName: item.role === 'user' ? userStore.userInfo.realname : 'AI助手',
fromAvatar: item.role === 'user' ? userStore.userInfo.avatar : '',
msgData: item.content,
msgType: item.images?.length ? 'image' : 'text',
sendTime: timeStr,
}
})
}
// 发送消息到界面
const send = ({ msgData, msgType, isAI = false, messageId = '' }) => {
const time = formatDate(new Date(), 'yyyy-MM-dd hh:mm:ss')
const timeStr = String(time || '')
const id = messageId || timeStr.replace(/:/g, '').replace(/-/g, '').replace(' ', '')
paging.value?.addChatRecordData([
{
fromUserName: isAI ? 'AI助手' : userStore.userInfo.realname,
msgFrom: isAI ? 'ai' : userStore.userInfo.userid,
msgData,
fromAvatar: isAI ? '' : userStore.userInfo.avatar,
sendTime: timeStr,
sendTimeId: id,
msgType,
},
])
return id
}
// 处理新建会话
const handleNewConversation = () => {
// 1. 检查是否已经是新会话(没有 conversationId 或 dataList 为空)
if (!conversationId.value || dataList.value.length === 0) {
toast.info('已经在新会话中')
return
}
// 2. 如果正在回答中,先停止回答
if (isAIAnswering.value) {
handleStopAnswer()
}
// 3. 清空所有相关数据
clearConversationData()
// 4. 显示空状态页面
paging.value?.complete([])
}
// 清空会话数据
const clearConversationData = () => {
// 清空会话 ID
conversationId.value = ''
// 清空消息列表
dataList.value = []
// 关闭 EventSource 连接
closeEventSource()
// 重置 AI 回答状态
isAIAnswering.value = false
currentAIMessageId.value = ''
currentAIMessage.value = ''
requestId.value = ''
// 重置标题为"新对话"
navTitle.value = '新对话'
}
// 更新 AI 消息(用于流式显示)
const updateAIMessage = (messageId: string, content: string) => {
// 查找并更新对应的消息
const index = dataList.value.findIndex(item => item.sendTimeId === messageId)
if (index !== -1) {
dataList.value[index].msgData = content
}
}
// 关闭 EventSource 连接
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
// 发送文本消息(使用 EventSource)
const doSend = async (textMsg: string) => {
const content = textMsg.trim()
if (!content) return
send({ msgData: content, msgType: 'text', isAI: false })
// 关闭之前的连接
closeEventSource()
try {
const params = {
appId: appId.value,
content,
conversationId: conversationId.value || '2c9180839a09dce2019a0ef293460037',
images: JSON.stringify([]),
responseMode: 'streaming',
topicId: '2c9180839a09dce2019a0ef293440036',
}
// ✅ 在条件编译之前声明 url 变量
let url = ''
// #ifdef H5
// H5 端使用原生 EventSource
const queryParams = new URLSearchParams(params)
url = `/jeecgboot${api.sendChat}?${queryParams.toString()}`
eventSource = new EventSource(url)
// #endif
// #ifdef APP-PLUS
// APP 端使用 EventSourcePolyfill
const queryString = qs.stringify(params)
url = `/jeecgboot${api.sendChat}?${queryString}`
console.log('app端>>>', url)
let sign = signMd5Utils.getSign(url, params)
let vSign = signMd5Utils.getVSign(params, sign)
eventSource = new EventSourcePolyfill(url, {
headers: {
'X-Access-Token': userStore.userInfo.token,
'X-Tenant-Id': userStore.userInfo.tenantId,
// 'X-Sign': sign,
// 'V-Sign': vSign,
// 'X-TIMESTAMP': signMd5Utils.getTimestamp(),
},
heartbeatTimeout: 120000,
})
// #endif
isAIAnswering.value = true
currentAIMessage.value = ''
currentAIMessageId.value = ''
// 监听消息事件
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('收到 SSE 消息:', data)
handleSSEMessage(data)
} catch (e) {
console.error('解析 SSE 消息失败', e)
}
}
// 监听错误事件
eventSource.onerror = (error) => {
console.error('SSE 连接错误', error)
closeEventSource()
isAIAnswering.value = false
if (!currentAIMessage.value) {
toast.error('连接失败,请重试')
}
}
} catch (e) {
console.error('发送消息失败', e)
toast.error('发送失败')
isAIAnswering.value = false
}
}
const doSend2 = async (textMsg: string) => {
// #ifdef H5
await doSendForH5(textMsg)
// #endif
// #ifdef APP-PLUS
await doSendForApp(textMsg)
// #endif
}
// APP 端使用 uni.request 解析 SSE 流
const doSendForApp = async (textMsg: string) => {
const content = textMsg.trim()
if (!content) return
send({ msgData: content, msgType: 'text', isAI: false })
try {
const params = {
appId: appId.value,
content,
conversationId: conversationId.value || '2c9180839a09dce2019a0ef293460037',
images: JSON.stringify([]),
responseMode: 'streaming',
topicId: '2c9180839a09dce2019a0ef293440036',
}
// 使用 qs.stringify 构建查询字符串
const queryString = qs.stringify(params)
const url = `/jeecgboot${api.sendChat}?${queryString}`;
// let params = options.query
// if (options.data && Object.keys(options.data).length > 0) {
// params = Object.assign({}, options?.query, options?.data)
// }
let sign = signMd5Utils.getSign(url, params)
let vSign = signMd5Utils.getVSign(params, sign)
isAIAnswering.value = true
currentAIMessage.value = ''
currentAIMessageId.value = ''
console.log('请求地址>>>', url)
const downloadTask = uni.downloadFile({
url: `/jeecgboot${api.sendChat}?${queryString}`,
header: {
'X-Access-Token': userStore.userInfo.token,
'X-Tenant-Id': userStore.userInfo.tenantId,
'Accept': 'text/event-stream',
'X-Sign': sign,
'V-Sign': vSign,
'X-TIMESTAMP': signMd5Utils.getTimestamp(),
},
success: (res) => {
console.log('下载完成', res)
if (res.statusCode === 200) {
uni.getFileSystemManager().readFile({
filePath: res.tempFilePath,
encoding: 'utf8',
success: (fileRes) => {
const content = fileRes.data
parseSSEChunk(content)
}
})
}
},
fail: (err) => {
console.error('请求失败', err)
toast.error('连接失败,请重试')
isAIAnswering.value = false
}
})
downloadTask.onProgressUpdate((res) => {
console.log('下载进度', res.progress)
})
} catch (e) {
console.error('发送消息失败', e)
toast.error('发送失败')
isAIAnswering.value = false
}
}
// 解析 SSE 数据块
let sseBuffer = ''
const parseSSEChunk = (chunk: string) => {
sseBuffer += chunk
// 按行分割
const lines = sseBuffer.split('\n')
sseBuffer = lines.pop() || '' // 保留最后一个不完整的行
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const jsonStr = line.substring(5).trim()
if (jsonStr) {
const data = JSON.parse(jsonStr)
handleSSEMessage(data)
}
} catch (e) {
console.error('解析 SSE 消息失败', e, line)
}
}
}
}
const doSendForH5 = async (textMsg: string) => {
const content = textMsg.trim()
if (!content) return
// 先在界面显示用户消息
send({ msgData: content, msgType: 'text', isAI: false })
// 关闭之前的连接
closeEventSource()
try {
const token = uni.getStorageSync('token')
// 构建 URL 参数
const params = new URLSearchParams({
appId: appId.value,
content,
conversationId: conversationId.value || '2c9180839a09dce2019a0ef293460037',
images: JSON.stringify([]),
responseMode: 'streaming',
topicId: '2c9180839a09dce2019a0ef293440036',
})
const url = `/jeecgboot${api.sendChat}?${params.toString()}`
// 创建 EventSource 连接
eventSource = new EventSource(url)
// 重置当前消息
currentAIMessage.value = ''
currentAIMessageId.value = ''
// 监听消息事件
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('收到 SSE 消息:', data)
handleSSEMessage(data)
} catch (e) {
console.error('解析 SSE 消息失败', e)
}
}
// 监听错误事件
eventSource.onerror = (error) => {
console.error('SSE 连接错误', error)
closeEventSource()
// 如果还没有收到任何消息,显示错误提示
if (!currentAIMessage.value) {
toast.error('连接失败,请重试')
}
}
} catch (e) {
console.error('发送消息失败', e)
toast.error('发送失败')
}
}
// 处理 SSE 消息
const handleSSEMessage2 = (data: any) => {
switch (data.event) {
case 'INIT_REQUEST_ID':
// 初始化请求 ID
requestId.value = data.requestId
if (data.conversationId) {
conversationId.value = data.conversationId
}
console.log('初始化请求:', data)
break
case 'MESSAGE':
// 流式消息块
const message = data.data?.message || ''
currentAIMessage.value += message
// 如果是第一次收到消息,创建 AI 消息项
if (!currentAIMessageId.value) {
currentAIMessageId.value = send({
msgData: currentAIMessage.value,
msgType: 'text',
isAI: true,
})
} else {
// 更新现有消息
updateAIMessage(currentAIMessageId.value, currentAIMessage.value)
}
break
case 'MESSAGE_END':
// 消息结束
console.log('消息接收完成')
closeEventSource()
// 重置状态
currentAIMessage.value = ''
currentAIMessageId.value = ''
break
case 'ERROR':
case 'FLOW_ERROR':
// 错误处理
const errorMsg = data.data?.message || '发送失败'
toast.error(errorMsg)
closeEventSource()
// 重置状态
currentAIMessage.value = ''
currentAIMessageId.value = ''
break
default:
console.warn('未知的消息类型:', data.event)
}
}
// 修改 handleSSEMessage 函数
const handleSSEMessage = (data: any) => {
switch (data.event) {
case 'INIT_REQUEST_ID':
requestId.value = data.requestId
if (data.conversationId) {
conversationId.value = data.conversationId
}
isAIAnswering.value = true // 开始回答
break
case 'MESSAGE':
const message = data.data?.message || ''
currentAIMessage.value += message
if (!currentAIMessageId.value) {
currentAIMessageId.value = send({
msgData: currentAIMessage.value,
msgType: 'text',
isAI: true,
})
} else {
updateAIMessage(currentAIMessageId.value, currentAIMessage.value)
}
break
case 'MESSAGE_END':
console.log('消息接收完成')
closeEventSource()
isAIAnswering.value = false // 回答结束
currentAIMessage.value = ''
currentAIMessageId.value = ''
break
case 'ERROR':
case 'FLOW_ERROR':
const errorMsg = data.data?.message || '发送失败'
toast.error(errorMsg)
closeEventSource()
isAIAnswering.value = false // 回答结束
currentAIMessage.value = ''
currentAIMessageId.value = ''
break
}
}
// 停止回答
const handleStopAnswer = async () => {
if (!requestId.value) return
try {
await http.post(api.stopChat, { requestId: requestId.value })
closeEventSource()
isAIAnswering.value = false
toast.success('已停止回答')
} catch (e) {
console.error('停止回答失败', e)
toast.error('停止失败')
}
}
// 复制消息
const handleCopy = (item: any) => {
uni.setClipboardData({
data: item.msgData.replace(/<[^>]+>/g, ''),
success: () => {
// toast.success('复制成功')
}
})
}
// 重新生成
const handleRegenerate = (item: any) => {
// 找到该消息之前的用户消息
const index = dataList.value.findIndex(i => i.sendTimeId === item.sendTimeId)
if (index > 0) {
const userMsg = dataList.value[index - 1]
if (userMsg.msgFrom === userStore.userInfo.userid) {
doSend(userMsg.msgData)
}
}
}
// 处理图片上传
const handleImage = async (type: string) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: [type],
success: async (res) => {
uni.showLoading({ title: '上传中...' })
try {
const uploadRes = await uni.uploadFile({
url: http.baseUrl + api.uploadUrl,
filePath: res.tempFilePaths[0],
name: 'file',
header: {
'X-Access-Token': uni.getStorageSync('token'),
},
})
const data = JSON.parse(uploadRes.data)
if (data.success) {
send({ msgData: data.message, msgType: 'image' })
} else {
toast.error('上传失败')
}
} catch (e) {
toast.error('上传失败')
} finally {
uni.hideLoading()
}
},
})
}
// 选择会话
const handleSelectConversation = (item) => {
conversationId.value = item.id
navTitle.value = item.title || 'AI助手'
loadAllMessages() // 改为调用新的加载函数
}
init()
onLoad(() => {
uni.hideTabBar()
})
onShow(() => {
uni.hideTabBar()
})
onMounted(() => { })
onBeforeUnmount(() => {
// 页面卸载时关闭 EventSource 连接
closeEventSource()
})
</script>
<style lang="scss" scoped>
:deep(.pageContent) {
background: transparent !important;
}
.wrap {
height: 100%;
background-image: url('@/static/bgImg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
.z-paging-content {
background: transparent; // 移除原有的渐变背景
}
.stop-answer-btn {
position: fixed;
bottom: 400rpx; // 输入框上方
left: 50%;
transform: translateX(-50%);
width: 208rpx;
height: 64rpx;
border-radius: 40rpx;
background: #FFFFFF;
border: 2rpx solid #E2E2E2;
display: flex;
justify-content: center;
align-items: center;
padding: 12rpx 24rpx;
box-sizing: border-box;
z-index: 100;
text {
font-size: 28rpx;
color: #333;
}
&:active {
background: #f5f5f5;
}
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.empty-logo {
width: 67.74upx;
height: 80upx;
margin-bottom: 24upx;
}
.empty-title {
font-family: Alibaba PuHuiTi 3.0;
font-size: 40upx;
font-weight: 600;
line-height: normal;
letter-spacing: 0px;
color: rgba(0, 0, 0, 0.9);
margin-bottom: 24upx;
}
.empty-subtitle {
font-family: Alibaba PuHuiTi 3.0;
font-size: 28upx;
font-weight: normal;
line-height: 48upx;
text-align: center;
color: rgba(0, 0, 0, 0.6);
}
}
}
:deep(.zp-page-bottom-container) {
background: transparent !important;
margin: 24upx;
}
</style>
现在在H5端正常,但是app端报错:发送消息失败, TypeError: XMLHttpRequest2 is not a constructor at pages/knowledge/AiKnowledgeManagerList.vue