pet/pages/assistant/assistant.vue

996 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="assistant-container page-container-unified">
<!-- 宠物助手信息卡片 -->
<view class="assistant-info-card">
<view class="assistant-avatar-wrapper">
<view class="assistant-avatar">
<text class="avatar-emoji">🤖</text>
</view>
</view>
<view class="assistant-info-text">
<text class="assistant-name">宠物助手</text>
<text class="assistant-status">专业的宠物护理顾问</text>
</view>
<view class="chat-status">
<view class="status-dot"></view>
<text class="status-text">在线</text>
</view>
<view class="action-buttons">
<view class="action-btn" @click="clearHistory" title="清空历史">
<text class="icon-text">🗑</text>
</view>
<view class="action-btn" @click="openKnowledge" title="知识库">
<text class="icon-text">📚</text>
</view>
</view>
</view>
<!-- 聊天消息列表 -->
<view class="chat-messages">
<scroll-view
class="chat-scroll"
scroll-y
:scroll-top="scrollTop"
scroll-with-animation
>
<view class="message-list">
<!-- 消息项 -->
<template v-for="(message, index) in messageList" :key="index">
<!-- AI消息 -->
<view class="message-item ai" v-if="message.type === 'ai'">
<view class="message-avatar">
<view class="assistant-avatar">
<text class="avatar-emoji">🤖</text>
</view>
</view>
<view class="message-content ai">
<view class="message-bubble ai" @longpress="copyMessage(message.content)">
<view class="message-text" v-if="!(isThinking && index === messageList.length - 1)">
<u-markdown :content="message.content" :showLine="false"></u-markdown>
</view>
<view class="typing-dots" v-else>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
</view>
<text class="message-time" v-if="!(isThinking && index === messageList.length - 1)">{{ message.time }}</text>
</view>
</view>
<!-- 用户消息 -->
<view class="message-item user" v-if="message.type === 'user'">
<view class="message-content user">
<view class="message-bubble user" @longpress="copyMessage(message.content)">
<view class="message-text">
<u-markdown :content="message.content" :showLine="false"></u-markdown>
</view>
</view>
<text class="message-time">{{ message.time }}</text>
</view>
<view class="message-avatar">
<view class="user-avatar">
<text class="avatar-emoji">👤</text>
</view>
</view>
</view>
</template>
<!-- AI思考中状态 -->
<view class="message-item ai" v-if="isThinking">
<view class="message-avatar">
<view class="assistant-avatar">
<text class="avatar-emoji">🤖</text>
</view>
</view>
<view class="message-content ai">
<view class="message-bubble ai typing-bubble">
<view class="typing-dots">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 输入区域 -->
<view class="chat-input-area">
<view class="input-container">
<view class="input-wrapper">
<input
v-model="inputMessage"
placeholder="和宠物助手说点什么..."
class="message-input"
@confirm="sendMessage"
confirm-type="send"
maxlength="500"
/>
</view>
<view class="send-button" @click="sendMessage" :class="{ active: inputMessage.trim() }">
<text class="send-text">发送</text>
</view>
</view>
</view>
</view>
</template>
<script>
import {
askPetAssistant,
streamAskPetAssistant,
getAssistantHistory,
clearAssistantHistory,
validateMessage,
formatChatMessage,
formatMessageTime
} from '@/http/api/assistant.js'
import { createPetAssistantSSE } from '@/utils/sse.js'
export default {
data() {
return {
inputMessage: '',
scrollTop: 0,
isThinking: false,
userAvatar: '/static/user-avatar.png',
messageList: []
}
},
onLoad(options) {
// 加载历史记录
this.loadChatHistory()
},
onShow() {
// 页面显示时滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
})
},
methods: {
getCurrentTime() {
const now = new Date()
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
},
async sendMessage() {
if (!this.inputMessage.trim() || this.isThinking) return
// 验证消息内容
const validation = validateMessage(this.inputMessage)
if (!validation.valid) {
uni.showToast({
title: validation.error,
icon: 'none',
duration: 2000
})
return
}
// 保存用户输入内容
const userInput = this.inputMessage.trim()
// 添加用户消息
const userMessage = {
type: 'user',
content: userInput,
time: this.getCurrentTime()
}
this.messageList.push(userMessage)
// 清空输入框
this.inputMessage = ''
// 开始AI思考
this.isThinking = true
// 确保DOM更新后再滚动到思考状态
this.$nextTick(() => {
this.forceScrollToBottom()
// 在思考过程中持续滚动,确保思考动画可见
setTimeout(() => {
this.forceScrollToBottom()
}, 200)
setTimeout(() => {
this.forceScrollToBottom()
}, 500)
})
try {
// 使用SSE流式响应
const response = await this.handleStreamResponse({
message: userInput,
temperature: 0.7,
maxTokens: 1000
})
// 检查是否包含敏感词
if (response && response.data && response.data.isSensitive) {
uni.showToast({
title: '消息包含敏感内容',
icon: 'none',
duration: 2000
})
}
// 结束思考状态
this.isThinking = false
} catch (error) {
console.error('SSE流式请求失败尝试普通请求:', error)
// 降级处理使用普通API
try {
const fallbackResponse = await askPetAssistant({
message: userInput,
temperature: 0.7,
maxTokens: 1000
})
if (fallbackResponse && fallbackResponse.data) {
const aiMessage = {
type: 'ai',
content: fallbackResponse.data.message || fallbackResponse.data.content || '抱歉,我现在无法回答您的问题。',
time: this.getCurrentTime(),
isSensitive: fallbackResponse.data.isSensitive || false,
tokenCount: fallbackResponse.data.tokenCount || 0,
responseTime: fallbackResponse.data.responseTime || 0
}
// 检查敏感词
if (fallbackResponse.data.isSensitive) {
uni.showToast({
title: '消息包含敏感内容',
icon: 'none',
duration: 2000
})
}
this.messageList.push(aiMessage)
this.forceScrollToBottom()
} else {
this.handleApiError('API响应格式异常')
}
} catch (fallbackError) {
console.error('普通请求也失败:', fallbackError)
this.handleApiError(fallbackError.message || '网络请求失败')
}
} finally {
// 确保思考状态结束(防止异常情况)
this.isThinking = false
}
},
/**
* 处理API错误
* @param {string} errorMessage 错误信息
*/
handleApiError(errorMessage) {
const errorMessage_display = {
type: 'ai',
content: `抱歉,我遇到了一些问题:${errorMessage}\n\n请稍后再试或者检查网络连接。`,
time: this.getCurrentTime(),
isError: true
}
// 直接添加错误消息
this.messageList.push(errorMessage_display)
this.forceScrollToBottom()
},
/**
* 加载聊天历史记录
*/
async loadChatHistory() {
try {
const response = await getAssistantHistory({
page: 1,
pageSize: 50
})
if (response && response.data && response.data.list && response.data.list.length > 0) {
// 格式化并设置历史消息
const historyMessages = response.data.list.map(msg => formatChatMessage(msg))
this.messageList = historyMessages
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
})
} else {
// 没有历史记录,保持空列表
this.messageList = []
}
} catch (error) {
console.error('加载历史记录失败:', error)
// 静默处理,保持空列表
this.messageList = []
}
},
/**
* 清空聊天历史
*/
async clearHistory() {
try {
const result = await uni.showModal({
title: '确认清空',
content: '确定要清空聊天记录吗?此操作不可恢复。',
confirmText: '清空',
cancelText: '取消'
})
if (!result.confirm) return
uni.showLoading({
title: '正在清空...'
})
await clearAssistantHistory()
// 清空消息列表
this.messageList = []
uni.showToast({
title: '历史记录已清空',
icon: 'success',
duration: 1500
})
} catch (error) {
console.error('清空历史记录失败:', error)
uni.showToast({
title: '清空失败',
icon: 'none',
duration: 2000
})
} finally {
uni.hideLoading()
}
},
/**
* 处理SSE流式响应
* @param {Object} messageData 消息数据
* @returns {Promise} 返回流式响应处理结果
*/
async handleStreamResponse(messageData) {
// 创建AI消息对象
const aiMessage = {
type: 'ai',
content: '',
time: this.getCurrentTime(),
isSensitive: false,
tokenCount: 0,
responseTime: 0
}
// 添加到消息列表
this.messageList.push(aiMessage)
// 使用SSE工具类处理流式响应
return createPetAssistantSSE(messageData, {
onChunk: (chunk) => {
// 实时更新消息内容
aiMessage.content = chunk.totalContent
this.forceScrollToBottom()
},
onComplete: (result) => {
// 更新消息的元数据
if (result.data.isSensitive !== undefined) {
aiMessage.isSensitive = result.data.isSensitive
}
if (result.data.tokenCount !== undefined) {
aiMessage.tokenCount = result.data.tokenCount
}
if (result.data.responseTime !== undefined) {
aiMessage.responseTime = result.data.responseTime
}
},
onError: (error) => {
console.error('SSE流式响应失败:', error)
}
})
},
scrollToBottom() {
this.$nextTick(() => {
this.scrollTop = 999999
})
},
// 强制滚动到底部,用于确保消息可见
forceScrollToBottom() {
this.$nextTick(() => {
// 使用时间戳确保每次滚动都能触发
this.scrollTop = Date.now()
setTimeout(() => {
this.scrollTop = 999999
}, 50)
})
},
copyMessage(content) {
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({
title: '已复制到剪贴板',
icon: 'success',
duration: 1500
});
},
fail: () => {
uni.showToast({
title: '复制失败',
icon: 'none',
duration: 1500
});
}
});
},
openKnowledge() {
uni.navigateTo({
url: '/pages/assistant/knowledge'
})
},
}
}
</script>
<style lang="scss" scoped>
.assistant-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
position: relative;
}
.assistant-info-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
margin: 0 0 0 0;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 32rpx rgba(255, 138, 128, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.3);
.assistant-avatar-wrapper {
margin-right: 24rpx;
.assistant-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #FF8A80, #FFB6C1);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 138, 128, 0.3);
.avatar-emoji {
font-size: 40rpx;
color: #ffffff;
}
}
}
.assistant-info-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.assistant-name {
font-size: 32rpx;
font-weight: 600;
color: #333333;
}
.assistant-status {
font-size: 24rpx;
color: #666666;
}
}
.chat-status {
display: flex;
align-items: center;
gap: 12rpx;
margin-right: 20rpx;
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #4CAF50;
box-shadow: 0 0 0 4rpx rgba(76, 175, 80, 0.2);
}
.status-text {
font-size: 24rpx;
color: #4CAF50;
font-weight: 500;
}
}
.action-buttons {
display: flex;
align-items: center;
gap: 16rpx;
.action-btn {
width: 56rpx;
height: 56rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.9);
}
.icon-text {
font-size: 24rpx;
}
}
}
}
.quick-questions {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
margin: 20rpx 0;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(255, 138, 128, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.3);
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #FF8A80;
margin-bottom: 20rpx;
display: block;
}
.question-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.question-button {
background: rgba(255, 138, 128, 0.1);
border: 2rpx solid rgba(255, 138, 128, 0.3);
border-radius: 20rpx;
padding: 16rpx 24rpx;
transition: all 0.3s ease;
&:active {
background: rgba(255, 138, 128, 0.2);
transform: scale(0.98);
}
.question-text {
font-size: 26rpx;
color: #FF8A80;
font-weight: 500;
}
}
}
}
.chat-messages {
margin-top: 24rpx;
flex: 1;
overflow: hidden;
}
.chat-scroll {
width: 100%;
height: 100%;
}
.message-list {
padding: 20rpx 0;
padding-bottom: 80rpx;
min-height: 100%;
box-sizing: border-box;
}
.message-item {
display: flex;
margin-bottom: 24rpx;
align-items: flex-start;
}
.message-item.user {
justify-content: flex-end;
padding: 0 0 0 40rpx;
}
.message-item.ai {
justify-content: flex-start;
padding: 0 40rpx 0 0;
}
.message-avatar {
width: 64rpx;
height: 64rpx;
margin: 0;
flex-shrink: 0;
}
/* AI消息头像 - 右边距 */
.message-item.ai .message-avatar {
margin-right: 16rpx;
}
/* 用户消息头像 - 左边距 */
.message-item.user .message-avatar {
margin-left: 16rpx;
}
.assistant-avatar, .user-avatar {
width: 100%;
height: 100%;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
}
.assistant-avatar {
background: linear-gradient(135deg, #FF8A80, #FFB6C1);
box-shadow: 0 8rpx 24rpx rgba(255, 138, 128, 0.3);
}
.user-avatar {
background: linear-gradient(135deg, #81C784, #A5D6A7);
box-shadow: 0 8rpx 24rpx rgba(129, 199, 132, 0.3);
}
.avatar-emoji {
color: #ffffff;
}
.message-content {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-content.user {
align-items: flex-end;
}
.message-content.ai {
align-items: flex-start;
}
.message-bubble {
padding: 20rpx 24rpx;
border-radius: 24rpx;
word-wrap: break-word;
position: relative;
}
.message-bubble.ai {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10rpx);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
border-bottom-left-radius: 8rpx;
}
.message-bubble.ai.typing-bubble {
padding: 24rpx;
}
.message-bubble.user {
background: linear-gradient(135deg, #FF8A80 0%, #FFB6C1 100%);
box-shadow: 0 4rpx 16rpx rgba(255, 138, 128, 0.3);
border-bottom-right-radius: 8rpx;
}
.message-text {
font-size: 26rpx;
line-height: 1.5;
color: #333333;
}
/* Markdown样式优化 */
.message-text .u-markdown {
font-size: 26rpx;
line-height: 1.5;
}
/* 重置Markdown内部样式 */
.message-text .u-markdown :deep(p) {
margin: 0;
padding: 0;
font-size: 26rpx;
line-height: 1.5;
color: #333333;
}
.message-text .u-markdown :deep(h1),
.message-text .u-markdown :deep(h2),
.message-text .u-markdown :deep(h3),
.message-text .u-markdown :deep(h4),
.message-text .u-markdown :deep(h5),
.message-text .u-markdown :deep(h6) {
margin: 8rpx 0 4rpx 0;
font-weight: 600;
color: #FF8A80;
}
.message-text .u-markdown :deep(ul),
.message-text .u-markdown :deep(ol) {
margin: 8rpx 0;
padding-left: 32rpx;
}
.message-text .u-markdown :deep(li) {
margin: 4rpx 0;
font-size: 26rpx;
line-height: 1.5;
}
.message-text .u-markdown :deep(code) {
background: rgba(255, 138, 128, 0.1);
padding: 2rpx 8rpx;
border-radius: 6rpx;
font-size: 24rpx;
color: #FF8A80;
}
.message-text .u-markdown :deep(pre) {
background: rgba(255, 138, 128, 0.05);
padding: 16rpx;
border-radius: 12rpx;
margin: 8rpx 0;
overflow-x: auto;
}
.message-text .u-markdown :deep(blockquote) {
border-left: 6rpx solid #FF8A80;
padding-left: 16rpx;
margin: 8rpx 0;
color: #666;
font-style: italic;
}
.message-bubble.user .message-text {
color: #ffffff;
}
/* 用户消息的Markdown样式 */
.message-bubble.user .message-text .u-markdown :deep(p),
.message-bubble.user .message-text .u-markdown :deep(li) {
color: #ffffff;
}
.message-bubble.user .message-text .u-markdown :deep(h1),
.message-bubble.user .message-text .u-markdown :deep(h2),
.message-bubble.user .message-text .u-markdown :deep(h3),
.message-bubble.user .message-text .u-markdown :deep(h4),
.message-bubble.user .message-text .u-markdown :deep(h5),
.message-bubble.user .message-text .u-markdown :deep(h6) {
color: #ffffff;
}
.message-bubble.user .message-text .u-markdown :deep(code) {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.message-bubble.user .message-text .u-markdown :deep(pre) {
background: rgba(255, 255, 255, 0.1);
}
.message-bubble.user .message-text .u-markdown :deep(blockquote) {
border-left-color: #ffffff;
color: rgba(255, 255, 255, 0.8);
}
.message-time {
margin-top: 8rpx;
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
}
.message-content.user .message-time {
text-align: right;
}
.message-content.ai .message-time {
text-align: left;
}
.typing-dots {
display: flex;
gap: 8rpx;
justify-content: center;
.typing-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #FF8A80;
animation: typing 1.4s infinite ease-in-out;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.chat-input-area {
backdrop-filter: blur(20rpx);
padding: 16rpx 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
}
.input-container {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 0 !important;
.input-wrapper {
flex: 1;
border-radius: 28rpx;
padding: 0 20rpx;
backdrop-filter: blur(15rpx);
border: 3rpx solid rgba(255, 255, 255, 0.98);
height: 56rpx;
display: flex;
align-items: center;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 0 0 1rpx rgba(255, 255, 255, 0.4),
0 2rpx 8rpx rgba(255, 255, 255, 0.15),
0 4rpx 16rpx rgba(255, 255, 255, 0.1),
inset 0 1rpx 0 rgba(255, 255, 255, 0.2);
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 25rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, transparent 50%, rgba(255, 255, 255, 0.05) 100%);
pointer-events: none;
}
&:focus-within {
border-color: rgba(255, 255, 255, 1);
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.6),
0 0 20rpx rgba(255, 255, 255, 0.3),
0 4rpx 12rpx rgba(255, 255, 255, 0.2),
0 8rpx 24rpx rgba(255, 255, 255, 0.15),
inset 0 1rpx 0 rgba(255, 255, 255, 0.3);
transform: translateY(-2rpx);
}
.message-input {
width: 100%;
height: 100%;
font-size: 28rpx;
color: #ffffff;
border: none;
outline: none;
background: transparent;
position: relative;
z-index: 1;
&::placeholder {
color: rgba(255, 255, 255, 0.95);
font-weight: 400;
text-shadow: 0 1rpx 2rpx rgba(255, 255, 255, 0.1);
}
}
}
.send-button {
height: 56rpx;
padding: 0 20rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 3rpx solid rgba(255, 255, 255, 0.85);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
position: relative;
backdrop-filter: blur(10rpx);
box-shadow:
0 0 0 1rpx rgba(255, 255, 255, 0.3),
0 2rpx 6rpx rgba(255, 255, 255, 0.12),
0 4rpx 12rpx rgba(255, 255, 255, 0.08),
inset 0 1rpx 0 rgba(255, 255, 255, 0.15);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 21rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 50%, rgba(255, 255, 255, 0.03) 100%);
pointer-events: none;
}
&:active {
transform: scale(0.96) translateY(1rpx);
}
.send-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
position: relative;
z-index: 1;
text-shadow: 0 1rpx 2rpx rgba(255, 255, 255, 0.1);
}
}
.send-button.active {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
border: 3rpx solid rgba(255, 255, 255, 0.98);
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.5),
0 0 16rpx rgba(255, 255, 255, 0.25),
0 4rpx 10rpx rgba(255, 255, 255, 0.18),
0 6rpx 20rpx rgba(255, 255, 255, 0.12),
inset 0 1rpx 0 rgba(255, 255, 255, 0.25);
transform: translateY(-2rpx);
&::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, transparent 50%, rgba(255, 255, 255, 0.06) 100%);
}
.send-text {
color: rgba(255, 255, 255, 0.98);
font-weight: 600;
text-shadow: 0 1rpx 3rpx rgba(255, 255, 255, 0.15);
}
}
}
</style>