JinShan_uniapp/pages/chat/chatPage.vue
2025-09-08 09:40:27 +08:00

759 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="chat-container">
<!-- AI生成内容提示 -->
<view class="ai-notice">
<text class="ai-notice-text">内容由AI生成仅供参考</text>
</view>
<!-- 聊天消息区域 -->
<scroll-view scroll-y :scroll-top="scrollTop" class="chat-messages" :scroll-with-animation="true"
@scroll="onScroll">
<view class="" style="padding: 10px 0;">
<view v-for="(msg, index) in messages" :key="index"
:class="['message', msg.sender === 'me' ? 'sent' : 'received']">
<image v-if="msg.sender !== 'me'" :src="botAvatar" class="avatar"></image>
<view :class="['message-bubble', msg.sender === 'me' ? 'me' : '']">
<!-- <text class="message-type-tag" v-if="msg.type !== 'normal'">
{{ msg.type === 'thinking' ? '思考中' : msg.type === 'answer' ? '回答' : '' }}
</text> -->
<view class="message-text" style="color: #999999;">{{ msg.thinkContent }}</view>
<view class="message-text" v-if="msg.answerType===2">{{ msg.showAnswer}}</view>
<view class="message-text" v-if="msg.answerType===1">
<view>
穿搭建议:
<view>
🧥上身:
<view>
{{`${msg.showAnswer.outfit.top.color+msg.showAnswer.outfit.top.style}👔,${msg.showAnswer.outfit.top.detail_description},${msg.showAnswer.outfit.top.reason}`}}
</view>
</view>
<view style="margin-top: 10px;">
🩳下身:
<view>
{{`${msg.showAnswer.outfit.bottom.color+msg.showAnswer.outfit.bottom.style},${msg.showAnswer.outfit.bottom.detail_description},${msg.showAnswer.outfit.bottom.reason}😎`}}
</view>
</view>
<view style="margin-top: 10px;">
🥾鞋子:
<view>
{{`${msg.showAnswer.outfit.shoes.color+msg.showAnswer.outfit.shoes.style},${msg.showAnswer.outfit.shoes.detail_description},${msg.showAnswer.outfit.shoes.reason}🏃🏻`}}
</view>
</view>
<view style="margin-top: 10px;">👒配饰:
<view v-for="(item,index) of msg.showAnswer.outfit.accessories">
<view>
{{`${index+1}.`+item.color+item.type+','+item.detail_description+','+item.reason}}
</view>
</view>
</view>
</view>
<view style="margin-top: 10px;">
💅化妆建议:
<view>
<view class="">
{{msg.showAnswer.makeup_suggestion.style+','+msg.showAnswer.makeup_suggestion.description}}
</view>
<view style="margin-top: 10px;">
🧩产品推荐:
<view v-for="(item,index) of msg.showAnswer.makeup_suggestion.products">
<view class="">
{{`${index+1}.`+item.name+','+item.detail_description+','+item.reason}}
</view>
</view>
</view>
</view>
</view>
<view style="margin-top: 10px;">
📝风格推荐:
<view class="">{{msg.showAnswer.styling_tips.color_coordination}}</view>
<view class="">{{msg.showAnswer.styling_tips.occasion_adaptation}}</view>
<view v-for="(item,index) of msg.showAnswer.styling_tips.detail_enhancements">
<view class="">{{`${index+1}.${item}`}}</view>
</view>
</view>
</view>
<view class="typing-indicator" v-if="msg.isTyping">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
<text class="typing-text">对方正在输入...</text>
</view>
</view>
<image v-if="msg.sender === 'me'" :src="userAvatar" class="avatar"></image>
</view>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="chat-bottom">
<!-- <view class="" style="padding: 10px;">
<view class="aiTag" v-if="aiName==='生活咨询'" @click="sendMessageCeshi(0)">
最近雨季来了家里总是很潮湿有什么简单有效的除湿防霉小妙招吗
</view>
<view class="aiTag" v-if="aiName==='穿搭顾问'" @click="sendMessageCeshi(1)">
我下周要参加一个商务休闲风格的客户午餐会夏天户外庭院身高175cm体型偏瘦肤色偏白有什么搭配建议吗
</view>
<view class="aiTag" v-if="aiName==='膳食管家'" @click="sendMessageCeshi(2)">
我想开始减脂能为我设计一份适合上班族操作简单营养均衡的晚餐食谱吗
</view>
</view> -->
<view class="chat-input">
<view class="" style="flex: 1;">
<input class="input-field" placeholder="输入消息..." v-model="inputMsg" @confirm="sendMessage"
confirm-type="send" />
</view>
<view class="" style="width: 100px;">
<u-button type="primary" :disabled="!inputMsg.trim()||isAnswering" @click="sendMessage"
shape="circle">
发送
</u-button>
</view>
</view>
</view>
</view>
</template>
<script>
import {
WS_BASE_URL,
IMAGE_BASE_URL
} from '@/utils/config';
export default {
data() {
return {
inputMsg: '',
scrollTop: 0,
userAvatar: require('../../static/imgs/index/nav.png'),
botAvatar: require('../../static/imgs/ai/chuandaguwen.png'),
messages: [],
socketTask: null, // 微信小程序的 WebSocket 任务对象
isConnected: false,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectDelay: 3000,
userId: null,
userName: '默认用户',
apiKey: '',
serviceUrl: '',
currentAnswer: '',
isAnswering: false,
isHandleClose: false,
connectNum: 0,
aiName: '',
aiDefaultList: [
'最近雨季来了,家里总是很潮湿,有什么简单有效的除湿防霉小妙招吗?',
'我下周要参加一个商务休闲风格的客户午餐会夏天户外庭院身高175cm体型偏瘦肤色偏白有什么搭配建议吗',
'我想开始减脂,能为我设计一份适合上班族、操作简单、营养均衡的晚餐食谱吗?'
]
}
},
onLoad(options) {
this.serviceUrl = options.serviceUrl || '';
this.apiKey = options.apiKey || '';
this.botAvatar = options.icon || this.botAvatar;
this.aiName = options.name
const userInfo = wx.getStorageSync('userInfo') || {};
this.userId = userInfo.id || Date.now().toString();
this.userName = userInfo.name || '默认用户';
this.userAvatar = userInfo.avatar ? `${IMAGE_BASE_URL+userInfo.avatar}` : this.userAvatar;
uni.setNavigationBarTitle({
title: options.name || 'AI助手'
});
this.initWebSocket();
},
onUnload() {
this.closeWebSocket();
},
methods: {
// 初始化WebSocket连接
initWebSocket() {
if (this.isConnected) return;
console.log(WS_BASE_URL,'aiName===');
// 微信小程序中使用 wx.connectSocket
this.socketTask = wx.connectSocket({
url: `${WS_BASE_URL}/api/v1/ws/ai?apiUrl=${encodeURIComponent(this.serviceUrl)}&apiToken=${this.apiKey}&userName=${this.userName}`,
success: () => {
console.log('WebSocket连接创建成功');
},
fail: (err) => {
console.error('WebSocket连接创建失败', err);
this.handleReconnect();
}
});
// 监听 WebSocket 事件
this.socketTask.onOpen(() => {
console.log('WebSocket连接已打开');
this.isConnected = true;
// this.reconnectAttempts = 0;
if (this.reconnectAttempts === 0) {
const params = {
sender: 'bot',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: false,
showAnswer: '已连接,请输入您的问题',
answerType: 2
}
this.addMessage(params);
}
});
this.socketTask.onMessage((res) => {
// console.log('收到WebSocket消息:', res.data);
this.processBotMessage(res.data);
});
this.socketTask.onError((err) => {
console.error('WebSocket发生错误:', err);
this.isConnected = false;
const params = {
sender: 'bot',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: false,
showAnswer: '连接出错,请稍后再试',
answerType: 2
}
this.addMessage(params);
this.handleReconnect();
});
this.socketTask.onClose(() => {
console.log('WebSocket连接已关闭');
this.isConnected = false;
if (!this.isHandleClose) {
this.handleReconnect();
}
});
},
processBotMessage(data) {
try {
// 解析数据
const messageData = typeof data === 'string' ? JSON.parse(data) : data;
if (messageData.event === 'text_chunk') {
// 处理分块文本
this.handleTextChunk(messageData);
} else if (messageData.event === 'workflow_finished') {
this.currentAnswer = ''
// 处理完整节点完成
this.handleNodeFinished(messageData);
}
} catch (error) {
console.error('处理机器人消息出错:', error);
const params = {
sender: 'bot',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: false,
showAnswer: '处理消息时出错: ' + error.message,
answerType: 2
}
this.addMessage(params);
}
},
// 处理文本分块
handleTextChunk(chunkData) {
const text = chunkData.data.text;
if (!text || text.trim() === '') return;
let isThinking = true
if (text.includes('<think>')) {
this.messages[this.messages.length - 1].type = 'thinking'
}
if (text.includes('</think>')) {
this.messages[this.messages.length - 1].type = 'answer'
}
// 判断是否是思考内容
// const isThinking = text.includes('<think>') ||(this.messages[this.messages.length - 1]?.type === 'thinking');
console.log(text, this.messages[this.messages.length - 1].type);
if (this.messages[this.messages.length - 1].type == 'thinking') {
this.handleThinkingContent(text);
}
if (this.messages[this.messages.length - 1].type == 'answer') {
this.handleAnswerContent(text);
}
},
// 处理思考内容
handleThinkingContent(text) {
// 提取思考内容,去除标签
const thinkContent = text.replace(/<\/?think>/g, '').trim();
if (!thinkContent) return;
// 查找最后一条思考消息
// const lastThinkMsgIndex = this.messages.findLastIndex(
// msg => msg.type === 'thinking'
// );
// if (lastThinkMsgIndex >= 0) {
// // 追加到现有思考消息
// this.messages[lastThinkMsgIndex].thinkContent += thinkContent;
// this.$forceUpdate();
// } else {
// // 创建新的思考消息
// this.addMessage('bot', thinkContent, 'thinking');
// }
this.messages[this.messages.length - 1].thinkContent += thinkContent
this.$forceUpdate();
this.scrollToBottom();
},
// 处理回答内容
handleAnswerContent(text) {
// 提取回答内容,去除标签
const answerContent = text.replace(/<\/?think>/g, '');
if (!answerContent) return;
// 查找最后一条回答消息
// const lastAnswerMsgIndex = this.messages.findLastIndex(
// msg => msg.type === 'answer' || msg.type === 'normal'
// );
// if (lastAnswerMsgIndex >= 0 && this.messages[lastAnswerMsgIndex].type === 'answer') {
// // 追加到现有回答消息
// this.messages[lastAnswerMsgIndex].answerContent += answerContent;
// this.$forceUpdate();
// } else {
// // 创建新的回答消息
// this.addMessage('bot', answerContent, 'answer');
// }
this.messages[this.messages.length - 1].answerContent += answerContent
// this.$forceUpdate();
this.scrollToBottom();
},
// 处理节点完成
handleNodeFinished(nodeData) {
// 标记最后一条消息为完成
if (this.messages.length > 0) {
const lastMsg = this.messages[this.messages.length - 1];
lastMsg.isComplete = true;
lastMsg.isTyping = false
// 如果最后一条是思考消息,我们不需要额外处理
if (lastMsg.type === 'thinking') {
return;
}
if (lastMsg.answerContent.includes('outfit')) {
const ls = lastMsg.answerContent.replace(/\n/g, '')
try {
console.log(lastMsg.answerContent)
const obj = JSON.parse(lastMsg.answerContent)
console.log(obj)
lastMsg.answerContent = obj
} catch (error) {
lastMsg.answerContent = '哎呀AI开小差了请重新提问'
}
lastMsg.answerType = 1 //1代表返回了正式的穿搭建议2代表非正式的问答
}
lastMsg.showAnswer = lastMsg.answerContent
this.isAnswering = false
// console.log(JSON.parse(lastMsg.answerContent));
}
// 只有当没有找到回答内容时才添加完整输出
// if (nodeData.data.outputs?.text) {
// const fullText = nodeData.data.outputs.text;
// const answerContent = fullText.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
// // 检查是否已经有相同内容的消息
// const hasSameContent = this.messages.some(
// msg => msg.content.includes(answerContent)
// );
// if (answerContent && !hasSameContent) {
// this.addMessage('bot', answerContent, 'answer');
// }
// }
this.scrollToBottom();
},
// 修改后的 addMessage 方法
addMessage(params) {
// const message = {
// sender: sender === 'bot' ? 'other' : 'me',
// thinkContent,
// answerContent,
// showAnswer,
// type, // 'thinking', 'answer', 或 'normal'
// isTyping,
// isComplete: true, // 默认完整流式消息会设为false
// timestamp: Date.now(),
// answerType,
// };
const message = {
...params,
sender: params.sender === 'bot' ? 'other' : 'me',
isComplete: true, // 默认完整流式消息会设为false
timestamp: Date.now(),
}
// 如果是流式消息,标记为未完成
if (message.type === 'thinking' || message.type === 'answer') {
message.isComplete = false;
}
this.messages.push(message);
this.scrollToBottom();
},
// 发送消息
sendMessage() {
const content = this.inputMsg.trim();
if (!content || !this.isConnected) return;
const params = {
sender: 'me',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: false,
showAnswer: content,
answerType: 2
}
this.addMessage(params);
this.isAnswering = true
// 微信小程序中使用 socketTask.send
this.socketTask.send({
data: content,
success: () => {
console.log('消息发送成功');
const params = {
sender: 'bot',
thinkContent: '思考中...',
answerContent: '',
type: 'normal',
isTyping: true,
showAnswer: '',
answerType: 2
}
// 添加机器人正在输入的提示
this.addMessage(params);
},
fail: (err) => {
console.error('消息发送失败', err);
const params = {
sender: 'bot',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: true,
showAnswer: '消息发送失败,请重试',
answerType: 2
}
this.addMessage(params);
}
});
this.inputMsg = '';
},
sendMessageCeshi(index) {
const content = this.aiDefaultList[index];
if (!content || !this.isConnected) return;
const params = {
sender: 'me',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: false,
showAnswer: content,
answerType: 2
}
this.addMessage(params);
this.isAnswering = true
// 微信小程序中使用 socketTask.send
this.socketTask.send({
data: content,
success: () => {
console.log('消息发送成功');
const params = {
sender: 'bot',
thinkContent: '思考中...',
answerContent: '',
type: 'normal',
isTyping: true,
showAnswer: '',
answerType: 2
}
// 添加机器人正在输入的提示
this.addMessage(params);
},
fail: (err) => {
console.error('消息发送失败', err);
const params = {
sender: 'bot',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: true,
showAnswer: '消息发送失败,请重试',
answerType: 2
}
this.addMessage(params);
}
});
this.inputMsg = '';
},
// 处理重连逻辑
handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('已达到最大重连次数');
const params = {
sender: 'bot',
thinkContent: '',
answerContent: '',
type: 'normal',
isTyping: false,
showAnswer: '连接已断开,请刷新页面重试',
answerType: 2
}
this.addMessage(params);
return;
}
this.reconnectAttempts++;
console.log(`尝试重新连接,第${this.reconnectAttempts}`);
setTimeout(() => {
this.initWebSocket();
}, this.reconnectDelay);
},
// 关闭WebSocket连接
closeWebSocket() {
if (this.socketTask) {
// 移除所有回调引用
this.socketTask.onClose = null;
this.socketTask.onError = null;
this.socketTask.onMessage = null;
this.socketTask.close();
this.socketTask = null;
this.isHandleClose = true
}
this.isConnected = false;
},
// 滚动到底部
scrollToBottom() {
this.$nextTick(() => {
this.scrollTop = 99999; // 足够大的值确保滚动到底部
});
},
onScroll(e) {
// 可以在这里实现加载历史消息
}
}
}
</script>
<style>
/* AI生成内容提示样式 */
.ai-notice {
background-color: #fffbe6; /* 浅黄色背景 */
border-bottom: 1px solid #ffe58f; /* 底部边框 */
padding: 8px 15px;
text-align: center;
}
.ai-notice-text {
font-size: 14px;
color: #faad14; /* 橙色文字 */
font-weight: 500;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f0f0f0;
}
.chat-messages {
flex: 1;
/* padding: 15px; */
background-color: #e5ddd5;
overflow-y: auto;
}
.message {
display: flex;
margin: 10px;
}
.sent {
justify-content: flex-end;
}
.received {
justify-content: flex-start;
}
.aiTag {
padding: 5px;
font-size: 14px;
background: #f0f0f0;
border-radius: 10px;
color: #666;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
margin: 0 8px;
}
.message-bubble {
max-width: 70%;
padding: 10px 15px;
border-radius: 18px;
background-color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.me {
background-color: #dcf8c6;
}
.message-text {
font-size: 14px;
line-height: 1.4;
color: #333333;
}
.chat-bottom {
background-color: white;
border-top: 1px solid #ddd;
}
.chat-input {
background-color: white;
border-top: 1px solid #ddd;
display: flex;
padding: 10px;
}
.input-field {
flex: 1;
padding: 8px 15px;
border-radius: 20px;
background-color: #f0f0f0;
margin-right: 10px;
}
.send-btn {
padding: 0 20px;
border-radius: 20px;
background-color: #07C160;
color: white;
}
.send-btn:disabled {
opacity: 0.5;
}
/* 对方正在输入提示 */
.typing-indicator {
display: flex;
align-items: center;
margin-top: 8px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #7f8c8d;
margin: 0 3px;
animation: typing 1.4s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: 0s;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
.typing-text {
font-size: 12px;
margin-left: 8px;
color: #555;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-5px);
}
}
/* 思考消息样式 */
.message-bubble.thinking {
background-color: #f0f0f0;
border-left: 4px solid #999;
font-style: italic;
color: #666;
}
/* 回答消息样式 */
.message-bubble.answer {
background-color: #f8f8f8;
border-left: 4px solid #07C160;
}
/* 错误消息样式 */
.message-bubble.error {
background-color: #ffeeee;
border-left: 4px solid #ff4d4f;
color: #ff4d4f;
}
/* 消息类型标签 */
.message-type-tag {
font-size: 12px;
color: #999;
margin-bottom: 4px;
font-weight: bold;
}
</style>