578 lines
15 KiB
Vue
578 lines
15 KiB
Vue
<template>
|
||
<view class="chat-container">
|
||
|
||
<!-- 聊天消息区域 -->
|
||
<scroll-view scroll-y :scroll-top="scrollTop" class="chat-messages" :scroll-with-animation="true"
|
||
@scroll="onScroll">
|
||
<view class="message-time" v-if="showDate">今天 {{ getTime() }}</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<view v-for="(msg, index) in messages" :key="index"
|
||
:class="['message', msg.sender === 'me' ? 'sent' : 'received']">
|
||
<image v-if="msg.sender !== 'me'" :src="currentChat.avatar" class="avatar"></image>
|
||
|
||
<view :class="['message-bubble', msg.sender === 'me' ? 'me' : '']">
|
||
<text class="message-text" v-if="msg.content">{{ msg.content }}</text>
|
||
<!-- 对方正在输入提示 -->
|
||
<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>
|
||
<!-- <text class="message-status">{{ msg.time }}</text> -->
|
||
</view>
|
||
<image v-if="msg.sender === 'me'" :src="myAvatar" class="avatar"></image>
|
||
</view>
|
||
|
||
|
||
</scroll-view>
|
||
|
||
<!-- 输入区域 -->
|
||
<view class="chat-input">
|
||
<view class="input-container">
|
||
<!-- <uni-icons type="plus" size="28" color="#666" class="action-icon"></uni-icons> -->
|
||
<input class="input-field" placeholder="输入消息..." v-model="inputMsg" @confirm="sendMessage"
|
||
@input="onInput" :focus="isFocus" />
|
||
<!-- <uni-icons type="mic" size="28" color="#666" class="action-icon"></uni-icons>
|
||
<uni-icons type="image" size="28" color="#666" class="action-icon"></uni-icons> -->
|
||
</view>
|
||
<button class="send-btn" :class="{active: inputMsg.trim() !== ''}" @click="sendMessage">
|
||
<uni-icons type="paperplane" size="24" color="#fff"></uni-icons>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import uniIcons from '@dcloudio/uni-ui/lib/uni-icons/uni-icons.vue'
|
||
import {IMAGE_BASE_URL,BASE_URL} from '@/utils/config';
|
||
export default {
|
||
components: {
|
||
uniIcons
|
||
},
|
||
data() {
|
||
return {
|
||
inputMsg: '',
|
||
scrollTop: 0,
|
||
isTyping: false,
|
||
isFocus: false,
|
||
showDate: true,
|
||
myAvatar: require('../../static/imgs/index/nav.png'),
|
||
currentChat: {
|
||
name: '张婷婷',
|
||
avatar: require('../../static/imgs/ai/chuandaguwen.png'),
|
||
status: '在线'
|
||
},
|
||
messages: [],
|
||
currentResponse: null, // 用于存储当前正在接收的响应
|
||
currentResponseId: null, // 用于标识当前响应
|
||
abortController: null, // 用于取消请求
|
||
isStreaming: false, // 标记是否正在接收流
|
||
userId: null,
|
||
serviceUrl:'',
|
||
apiKey:'',
|
||
aiId:null,
|
||
aiName:''
|
||
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.serviceUrl=options.serviceUrl
|
||
this.apiKey=options.apiKey
|
||
this.aiId=options.id
|
||
this.currentChat.avatar=options.icon
|
||
uni.setNavigationBarTitle({
|
||
title: options.name
|
||
})
|
||
},
|
||
mounted() {
|
||
try {
|
||
const userInfo = wx.getStorageSync('userInfo')
|
||
if (userInfo === undefined || userInfo === null||userInfo=='') {
|
||
const defaultValue=this.generateMiniProgramUUID()
|
||
wx.setStorageSync('userId', defaultValue)
|
||
this.userId=defaultValue
|
||
this.myAvatar=require('../../static/imgs/index/nav.png')
|
||
}else{
|
||
this.userId=userInfo.id
|
||
this.myAvatar=userInfo.avatar||require('../../static/imgs/index/nav.png')
|
||
}
|
||
} catch (e) {
|
||
const defaultValue=this.generateMiniProgramUUID()
|
||
wx.setStorageSync('userId', defaultValue)
|
||
this.userId=defaultValue
|
||
}
|
||
},
|
||
methods: {
|
||
generateMiniProgramUUID() {
|
||
const buffer = new Uint8Array(16);
|
||
wx.getRandomValues(buffer);
|
||
buffer[6] = (buffer[6] & 0x0f) | 0x40; // Version 4
|
||
buffer[8] = (buffer[8] & 0x3f) | 0x80; // Variant 10
|
||
return Array.from(buffer)
|
||
.map(b => b.toString(16).padStart(2, '0'))
|
||
.join('')
|
||
.replace(/(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, '$1-$2-$3-$4-$5');
|
||
},
|
||
async sendMessage() {
|
||
if (!this.inputMsg.trim() || this.isStreaming) return;
|
||
|
||
// 添加用户消息
|
||
const userMsg = {
|
||
sender: 'me',
|
||
content: this.inputMsg,
|
||
time: this.getTime()
|
||
};
|
||
this.messages.push(userMsg);
|
||
const userInput = this.inputMsg;
|
||
this.inputMsg = '';
|
||
this.scrollToBottom();
|
||
|
||
// 创建AI回复的消息对象
|
||
const aiMsg = {
|
||
sender: 'other',
|
||
content: '',
|
||
time: this.getTime(),
|
||
isTyping: true,
|
||
id: Date.now() // 唯一标识符
|
||
};
|
||
this.messages.push(aiMsg);
|
||
this.isTyping = true;
|
||
this.isStreaming = true;
|
||
|
||
try {
|
||
// 创建AbortController以便取消请求
|
||
// this.abortController = new AbortController();
|
||
const response = await this.streamResponse(userInput, aiMsg.id);
|
||
console.log('Stream completed:', response);
|
||
} catch (error) {
|
||
console.error('Stream error:', error);
|
||
if (error.name !== 'AbortError') {
|
||
this.updateAiMessage(aiMsg.id, '\n\n抱歉,请求过程中出现错误: ' + error.message);
|
||
}
|
||
} finally {
|
||
this.isTyping = false;
|
||
this.isStreaming = false;
|
||
// this.abortController = null;
|
||
}
|
||
},
|
||
|
||
async streamResponse(inputParams, messageId) {
|
||
return new Promise((resolve, reject) => {
|
||
// 使用uni.request进行流式请求
|
||
uni.request({
|
||
url: this.serviceUrl,
|
||
method: 'POST',
|
||
data: {
|
||
'query':inputParams,
|
||
"inputs": {
|
||
"content": inputParams
|
||
},
|
||
"response_mode": "blocking", //blocking,streaming
|
||
"user":this.userId
|
||
},
|
||
header: {
|
||
'Authorization': `Bearer ${this.apiKey}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
// signal: this.abortController?.signal, // 支持取消请求
|
||
// enableChunked: true, // 启用分块传输
|
||
success: (res) => {
|
||
// console.log(res)
|
||
// 处理流式数据
|
||
if (res.data) {
|
||
console.log(res.data)
|
||
console.log(res.data.data)
|
||
try {
|
||
let result=null
|
||
if(this.aiId==3){
|
||
const result = res.data.data.outputs.text;
|
||
// 提取实际内容(移除<think>标签)
|
||
const content = result.replace(/<think>[\s\S]*?<\/think>\n?/g, '');
|
||
this.updateAiMessage(messageId, content);
|
||
this.isStreaming = false
|
||
this.isTyping = false;
|
||
}else{
|
||
const result = res.data.answer;
|
||
// 提取实际内容(移除<think>标签)
|
||
const content = result.replace(/<think>[\s\S]*?<\/think>\n?/g, '');
|
||
this.updateAiMessage(messageId, content);
|
||
this.isStreaming = false
|
||
this.isTyping = false;
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error('Parse error:', e);
|
||
}
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
reject(new Error(err.errMsg));
|
||
},
|
||
complete: () => {
|
||
// 请求完成
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
// 更新消息内容
|
||
async updateAiMessage(id, newContent) {
|
||
const msg = this.messages.find(m => m.id === id);
|
||
if (msg) {
|
||
// 格式化JSON内容为更易读的形式
|
||
if (newContent.includes('{') && newContent.includes('}')) {
|
||
try {
|
||
const jsonObj = JSON.parse(newContent);
|
||
msg.content = await this.formatJsonToReadableText(jsonObj);
|
||
msg.isTyping = false
|
||
|
||
} catch (e) {
|
||
// 如果不是完整JSON,直接显示
|
||
msg.content = newContent;
|
||
}
|
||
} else {
|
||
msg.content = newContent;
|
||
msg.isTyping = false
|
||
}
|
||
|
||
this.scrollToBottom();
|
||
}
|
||
},
|
||
|
||
// 将JSON格式化为易读的文本
|
||
formatJsonToReadableText(jsonObj) {
|
||
let result = '';
|
||
|
||
// 处理穿搭部分
|
||
if (jsonObj.outfit) {
|
||
result += '👕 穿搭建议:\n';
|
||
result += `上衣: ${jsonObj.outfit.top.style} (${jsonObj.outfit.top.color})\n`;
|
||
result += `下装: ${jsonObj.outfit.bottom.style} (${jsonObj.outfit.bottom.color})\n`;
|
||
result += `鞋子: ${jsonObj.outfit.shoes.style} (${jsonObj.outfit.shoes.color})\n`;
|
||
|
||
if (jsonObj.outfit.accessories && jsonObj.outfit.accessories.length > 0) {
|
||
result += '配饰:\n';
|
||
jsonObj.outfit.accessories.forEach(acc => {
|
||
result += `- ${acc.type}: ${acc.style} (${acc.color})\n`;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 处理化妆建议
|
||
if (jsonObj.makeup_suggestion) {
|
||
result += '\n💄 化妆建议:\n';
|
||
result += `风格: ${jsonObj.makeup_suggestion.style}\n`;
|
||
result += `描述: ${jsonObj.makeup_suggestion.description}\n`;
|
||
|
||
if (jsonObj.makeup_suggestion.products && jsonObj.makeup_suggestion.products.length > 0) {
|
||
result += '推荐产品:\n';
|
||
jsonObj.makeup_suggestion.products.forEach(product => {
|
||
result += `- ${product.name}: ${product.detail_description}\n`;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 处理造型建议
|
||
if (jsonObj.styling_tips) {
|
||
result += '\n💡 造型小贴士:\n';
|
||
result += `色彩搭配: ${jsonObj.styling_tips.color_coordination}\n`;
|
||
|
||
if (jsonObj.styling_tips.detail_enhancements && jsonObj.styling_tips.detail_enhancements.length > 0) {
|
||
result += '细节提升:\n';
|
||
jsonObj.styling_tips.detail_enhancements.forEach(tip => {
|
||
result += `- ${tip}\n`;
|
||
});
|
||
}
|
||
|
||
if (jsonObj.styling_tips.occasion_adaptation) {
|
||
result += `场合适配: ${jsonObj.styling_tips.occasion_adaptation}\n`;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
},
|
||
|
||
// 取消请求
|
||
cancelStream() {
|
||
if (this.requestTask) {
|
||
this.requestTask.abort();
|
||
this.isTyping = false;
|
||
this.isStreaming = false;
|
||
this.requestTask = null;
|
||
}
|
||
|
||
},
|
||
receiveMessage() {
|
||
this.isTyping = true;
|
||
this.scrollToBottom();
|
||
|
||
setTimeout(() => {
|
||
const replies = [
|
||
"明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释明白了,谢谢您的解释",
|
||
"这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用",
|
||
"请问价格方面是怎样的?这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用",
|
||
"我稍后会仔细查看这些信息这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用",
|
||
"好的,我会考虑购买专业版这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用这个功能听起来很实用"
|
||
];
|
||
|
||
const newMsg = {
|
||
sender: 'other',
|
||
content: replies[Math.floor(Math.random() * replies.length)],
|
||
time: this.getTime()
|
||
};
|
||
|
||
this.messages.push(newMsg);
|
||
this.isTyping = false;
|
||
this.scrollToBottom();
|
||
}, 2000);
|
||
},
|
||
|
||
scrollToBottom() {
|
||
setTimeout(() => {
|
||
this.scrollTop = Math.floor(Math.random() * 1000) + 10000;
|
||
}, 100);
|
||
},
|
||
|
||
onScroll(e) {
|
||
// 可以在这里实现加载历史消息
|
||
},
|
||
|
||
onInput() {
|
||
// 模拟对方正在输入
|
||
if (this.inputMsg.trim() && !this.isTyping) {
|
||
this.isTyping = true;
|
||
setTimeout(() => {
|
||
this.isTyping = false;
|
||
}, 2000);
|
||
}
|
||
},
|
||
|
||
getTime() {
|
||
const now = new Date();
|
||
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
||
},
|
||
|
||
goBack() {
|
||
uni.navigateBack();
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
/* 通用样式 */
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.chat-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
/* 顶部导航栏 */
|
||
.chat-header {
|
||
background: linear-gradient(135deg, #3498db, #1a5276);
|
||
padding: 10px 15px;
|
||
color: white;
|
||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.header-content {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-left: 15px;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.user-details {
|
||
margin-left: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.user-status {
|
||
font-size: 12px;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* 消息区域 */
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
/* padding: 15px; */
|
||
background-color: rgb(229, 221, 213);
|
||
/* background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" opacity="0.05"><path d="M20,20 C30,15 40,25 50,20 C60,15 70,25 80,20" stroke="black" fill="none"/></svg>'); */
|
||
}
|
||
|
||
.message-time {
|
||
text-align: center;
|
||
color: #7f8c8d;
|
||
font-size: 12px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.sent {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.received {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
margin: 0 8px;
|
||
/* align-self: flex-end; */
|
||
}
|
||
|
||
.message-bubble {
|
||
max-width: 70%;
|
||
background: white;
|
||
border-radius: 18px;
|
||
padding: 10px 15px;
|
||
position: relative;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.me {
|
||
background: #dcf8c6;
|
||
border-bottom-right-radius: 4px;
|
||
}
|
||
|
||
.message-text {
|
||
font-size: 16px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.message-status {
|
||
font-size: 11px;
|
||
color: #7f8c8d;
|
||
text-align: right;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
/* 输入区域 */
|
||
.chat-input {
|
||
background: #f0f0f0;
|
||
padding: 10px 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
border-top: 1px solid #ddd;
|
||
}
|
||
|
||
.input-container {
|
||
flex: 1;
|
||
background: white;
|
||
border-radius: 25px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 5px 15px;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.input-field {
|
||
flex: 1;
|
||
border: none;
|
||
outline: none;
|
||
padding: 8px 0;
|
||
font-size: 16px;
|
||
background: transparent;
|
||
}
|
||
|
||
.action-icon {
|
||
margin: 0 5px;
|
||
}
|
||
|
||
.send-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background-color: #2196f3;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border: none;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.send-btn.active {
|
||
background-color: #3498db;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* 对方正在输入提示 */
|
||
.typing-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
margin: 10px 0 20px 10px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.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: 13px;
|
||
margin-left: 8px;
|
||
color: #555;
|
||
}
|
||
|
||
@keyframes typing {
|
||
|
||
0%,
|
||
60%,
|
||
100% {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
30% {
|
||
transform: translateY(-5px);
|
||
}
|
||
}
|
||
</style> |