940 lines
26 KiB
Vue
940 lines
26 KiB
Vue
<template>
|
||
<view class="chat-container">
|
||
<!-- 自定义导航栏 -->
|
||
<!-- <view class="status-bar-placeholder"></view>
|
||
|
||
<view class="custom-nav-bar">
|
||
<view class="nav-left" @click="toggleHistoryDrawer">
|
||
<image src="/static/history.svg" mode="aspectFit" class="nav-icon"></image>
|
||
</view>
|
||
<view class="nav-title">智水AI辅导员</view>
|
||
</view> -->
|
||
|
||
<!-- 消息列表 -->
|
||
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation="true"
|
||
show-scrollbar="false" enhanced="true" bounces="true"
|
||
@scroll="onScroll" @scrolltoupper="loadMoreHistory" upper-threshold="50">
|
||
<!-- 加载提示 -->
|
||
<view v-if="isLoadingHistory" class="loading-history">
|
||
<text>正在加载历史记录...</text>
|
||
</view>
|
||
|
||
<!-- 没有更多历史记录提示 -->
|
||
<view v-if="!hasMoreHistory && messages.length > 0" class="no-more-history">
|
||
<text>没有更多历史记录了</text>
|
||
</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<block v-for="(item, index) in messages" :key="item.messageId || index">
|
||
<view :class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
|
||
<!-- 用户/AI头像 -->
|
||
<image class="avatar" :src="item.avatar"></image>
|
||
<view class="message-content">
|
||
<!-- 文字内容 -->
|
||
<view v-if="item.content && item.sender === 'ai'" class="markdown-content"
|
||
v-html="renderMarkdown(item.content)"></view>
|
||
<text v-else-if="item.content">{{ item.content }}</text>
|
||
<!-- 图片内容 -->
|
||
<image v-if="item.image" :src="item.image" class="sent-image"></image>
|
||
|
||
<!-- AI 特有内容 -->
|
||
<view v-if="item.sender === 'ai'" class="ai-hint">
|
||
<!-- 引用来源部分 -->
|
||
<view v-if="item.retrieverResources && item.retrieverResources.length"
|
||
class="reference-section">
|
||
<text class="reference-title">引用来源:</text>
|
||
<!-- 遍历每个引用资源 -->
|
||
<view v-for="(ref, idx) in item.retrieverResources" :key="idx"
|
||
class="reference-item-wrapper">
|
||
<!-- 可点击的文档名 -->
|
||
<text class="doc-name-link" @click="toggleSingleReference(index, idx)">
|
||
{{ ref.document_name }}
|
||
</text>
|
||
|
||
<!-- 展开的详情(仅当前项) -->
|
||
<view v-if="showSingleReference[index] && showSingleReference[index][idx]"
|
||
class="reference-details-item">
|
||
<text class="reference-meta">{{ ref.name }}({{ ref.document_name }})</text>
|
||
<text class="reference-content" v-if="ref.content">{{ ref.content }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- AI操作区域(点赞/点踩) -->
|
||
<view class="ai-actions">
|
||
<text class="ai-text">回答由AI生成</text>
|
||
<view class="icon-group">
|
||
<img src="/static/good.svg" class="btn-icon"
|
||
@click="handleThumbUpClick(item.messageId)" />
|
||
<img src="/static/tread.svg" class="btn-icon"
|
||
@click="handleThumbDownClick(item.messageId)" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
</scroll-view>
|
||
|
||
<!-- 输入框和发送按钮 -->
|
||
<view class="input-container">
|
||
<!-- 历史记录抽屉组件 -->
|
||
<HistoryDrawer :visible="showHistoryDrawer" @close="toggleHistoryDrawer" @item-click="onHistoryItemClick" />
|
||
|
||
<!-- 消息输入框 -->
|
||
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send" />
|
||
<!-- 添加图片按钮 -->
|
||
<!-- <img src="/static/add.svg" class="add-icon" @click="selectImage" /> -->
|
||
<!-- 发送消息按钮 -->
|
||
<button class="send-button" @click="sendMessage">发送</button>
|
||
</view>
|
||
|
||
<!-- 悬浮按钮:固定在右下角 -->
|
||
<!-- <view class="ai-hover" @click="goHome">
|
||
<view class="ai-hover-content">
|
||
<text class="ai-hover-text">AI</text>
|
||
</view>
|
||
</view> -->
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
/* ========== 依赖 ========== */
|
||
import HistoryDrawer from '@/components/aiChat/HistoryDrawer.vue';
|
||
import {
|
||
createChatStream
|
||
} from '@/utils/ai_stream.js';
|
||
import {
|
||
sendFeedback,
|
||
getHistory
|
||
} from '@/api/aiChat/ai_index.js';
|
||
import MarkdownIt from 'markdown-it';
|
||
import DOMPurify from 'dompurify';
|
||
|
||
/* DOMPurify 白名单加固 */
|
||
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
|
||
});
|
||
|
||
const md = new MarkdownIt({
|
||
html: true,
|
||
linkify: true,
|
||
typographer: true
|
||
});
|
||
|
||
export default {
|
||
components: {
|
||
HistoryDrawer
|
||
},
|
||
data() {
|
||
return {
|
||
showHistoryDrawer: false,
|
||
inputMessage: '',
|
||
messages: [],
|
||
scrollTop: 0,
|
||
conversation_id: null,
|
||
showSingleReference: {},
|
||
currentCancel: null,
|
||
sending: false,
|
||
user: uni.getStorageSync('stuNo') || '',
|
||
userId: uni.getStorageSync('stuId') || '',
|
||
userName: uni.getStorageSync('stuName') || '',
|
||
// 分页加载相关字段
|
||
isLoadingHistory: false,
|
||
hasMoreHistory: true,
|
||
earliestMessageId: null,
|
||
scrollHeight: 0,
|
||
lastScrollTime: 0,
|
||
scrollDebounce: 100,
|
||
lastUserScrollTime: 0, // 添加这个字段用于智能滚动
|
||
};
|
||
},
|
||
|
||
/* ---------- 生命周期 ---------- */
|
||
onLoad() {
|
||
if (!this.user) {
|
||
this.$toast('请先登录');
|
||
setTimeout(() => uni.navigateTo({
|
||
url: '/pages/login/index'
|
||
}), 1500);
|
||
return;
|
||
}
|
||
|
||
this.conversation_id = uni.getStorageSync('conversation_id') || null;
|
||
|
||
// 先初始化消息,再滚动到底部
|
||
this.initChat().then(() => {
|
||
// 使用三重确保策略处理不同设备的渲染差异
|
||
const scrollWithRetry = (attempt = 0) => {
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
this.forceScrollToBottom();
|
||
|
||
// 第二次尝试(处理iOS等需要额外触发的设备)
|
||
setTimeout(() => {
|
||
this.forceScrollToBottom(); // 确保滚动到底部
|
||
|
||
// 第三次确保(针对动态内容加载的情况)
|
||
if (attempt < 2) {
|
||
setTimeout(() => {
|
||
const query = uni.createSelectorQuery()
|
||
.in(this);
|
||
query.select('.message-list')
|
||
.boundingClientRect(rect => {
|
||
if (rect && Math.abs(rect
|
||
.scrollHeight -
|
||
rect.height - this
|
||
.scrollTop) > 50) {
|
||
scrollWithRetry(
|
||
attempt + 1
|
||
); // 递归调用直到成功
|
||
}
|
||
}).exec();
|
||
}, attempt === 0 ? 500 : 800);
|
||
}
|
||
}, 200);
|
||
}, 100);
|
||
});
|
||
};
|
||
|
||
// 初始触发
|
||
scrollWithRetry();
|
||
}).catch(e => {
|
||
console.error('初始化失败:', e);
|
||
// 失败时仍尝试滚动
|
||
this.$nextTick(() => {
|
||
setTimeout(() => this.forceScrollToBottom(), 300);
|
||
});
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 页面显示时触发
|
||
*/
|
||
onShow() {
|
||
// 页面显示时确保滚动到底部
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
console.log('onShow: 尝试滚动到底部');
|
||
this.forceScrollToBottom();
|
||
}, 200);
|
||
});
|
||
},
|
||
|
||
/* ---------- 方法 ---------- */
|
||
methods: {
|
||
/**
|
||
* 返回首页
|
||
*/
|
||
goHome() {
|
||
uni.reLaunch({
|
||
url: '/pages/index/index'
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 强制滚动到底部
|
||
* @param {number} offset - 额外的偏移量,默认为0
|
||
*/
|
||
forceScrollToBottom(offset = 0) {
|
||
console.log('forceScrollToBottom: 开始执行滚动');
|
||
this.$nextTick(() => {
|
||
try {
|
||
const query = uni.createSelectorQuery().in(this);
|
||
query.select('.message-list').scrollOffset(res => {
|
||
if (res) {
|
||
console.log('forceScrollToBottom: 获取到scrollOffset', res);
|
||
console.log('forceScrollToBottom: 当前scrollTop', this.scrollTop);
|
||
|
||
// 使用scrollHeight,如果没有则使用一个大值
|
||
const scrollHeight = res.scrollHeight || 99999;
|
||
const targetScrollTop = scrollHeight + 1000;
|
||
|
||
// 先重置scrollTop,然后设置到底部
|
||
this.scrollTop = 0;
|
||
this.$nextTick(() => {
|
||
this.scrollTop = targetScrollTop;
|
||
console.log('forceScrollToBottom: 设置scrollTop为', targetScrollTop);
|
||
|
||
// 延迟再次确保滚动到底部
|
||
setTimeout(() => {
|
||
const finalScrollTop = (res.scrollHeight || 99999) + 1000;
|
||
this.scrollTop = finalScrollTop;
|
||
console.log('forceScrollToBottom: 延迟设置scrollTop完成', finalScrollTop);
|
||
}, 100);
|
||
});
|
||
} else {
|
||
console.log('forceScrollToBottom: 未获取到scrollOffset');
|
||
// 直接使用备用方案
|
||
this.scrollTop = 0;
|
||
this.$nextTick(() => {
|
||
this.scrollTop = 99999;
|
||
console.log('forceScrollToBottom: 使用备用方案1');
|
||
});
|
||
}
|
||
}).exec();
|
||
} catch (error) {
|
||
console.error('滚动到底部失败:', error);
|
||
// 备用方案:直接设置一个很大的值
|
||
this.scrollTop = 0;
|
||
this.$nextTick(() => {
|
||
this.scrollTop = 99999;
|
||
console.log('forceScrollToBottom: 使用备用方案2');
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
// forceScrollToTop() {
|
||
// this.$nextTick(() => {
|
||
// this.scrollTop = 0;
|
||
|
||
// // 双重确保滚动生效
|
||
// setTimeout(() => {
|
||
// this.scrollTop = 1;
|
||
// setTimeout(() => {
|
||
// this.scrollTop = 0;
|
||
// }, 50);
|
||
// }, 100);
|
||
// });
|
||
// },
|
||
|
||
/**
|
||
* 切换历史记录抽屉显示状态
|
||
*/
|
||
toggleHistoryDrawer() {
|
||
this.showHistoryDrawer = !this.showHistoryDrawer;
|
||
},
|
||
|
||
/**
|
||
* 渲染Markdown内容
|
||
* @param {string} text - 需要渲染的Markdown文本
|
||
* @returns {string} 安全的HTML内容
|
||
*/
|
||
renderMarkdown(text) {
|
||
return DOMPurify.sanitize(md.render(text || ''));
|
||
},
|
||
|
||
/**
|
||
* 显示提示消息
|
||
* @param {string} title - 提示内容
|
||
* @param {string} icon - 图标类型
|
||
*/
|
||
$toast(title, icon = 'none') {
|
||
uni.showToast({
|
||
title,
|
||
icon
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 历史记录点击处理
|
||
* @param {Object} item - 历史记录项
|
||
*/
|
||
onHistoryItemClick(item) {
|
||
// 清理当前消息和分页状态
|
||
this.messages = [];
|
||
this.isLoadingHistory = false;
|
||
this.hasMoreHistory = true;
|
||
this.earliestMessageId = null;
|
||
|
||
// 添加历史对话到消息列表
|
||
const userMessage = {
|
||
sender: 'user',
|
||
avatar: '/static/yonghu.png',
|
||
content: item.content,
|
||
image: '',
|
||
messageId: 'history-user-' + Date.now().toString()
|
||
};
|
||
|
||
const aiMessage = {
|
||
sender: 'ai',
|
||
avatar: '/static/AI.png',
|
||
content: item.reply,
|
||
retrieverResources: [],
|
||
image: '',
|
||
messageId: 'history-ai-' + Date.now().toString()
|
||
};
|
||
|
||
this.messages = [userMessage, aiMessage];
|
||
this.earliestMessageId = userMessage.messageId;
|
||
|
||
// 设置对话ID(如果有的话)
|
||
if (item.conversationId) {
|
||
this.conversation_id = item.conversationId;
|
||
uni.setStorageSync('conversation_id', this.conversation_id);
|
||
}
|
||
|
||
// 确保滚动到底部
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
this.scrollToBottom();
|
||
}, 300);
|
||
});
|
||
|
||
this.saveMessagesToLocal(); // 保存到本地
|
||
this.$toast('已加载历史对话', 'success');
|
||
},
|
||
|
||
/**
|
||
* 初始化聊天 - 从接口获取历史记录
|
||
*/
|
||
async initChat() {
|
||
if (!this.user) return this.initConversation();
|
||
|
||
// 重置状态
|
||
this.isLoadingHistory = false;
|
||
this.hasMoreHistory = true;
|
||
this.earliestMessageId = null;
|
||
this.messages = [];
|
||
|
||
try {
|
||
const res = await getHistory({
|
||
user: this.user,
|
||
conversationId: this.conversation_id || '',
|
||
limit: 20
|
||
});
|
||
|
||
console.log('API响应数据:', res); // 调试日志
|
||
|
||
if (res.code === 200 && res.data && Array.isArray(res.data.data)) {
|
||
// 新消息数组
|
||
const newMessages = [];
|
||
|
||
// 处理每条消息
|
||
res.data.data.forEach(msg => {
|
||
console.log('处理单条消息:', msg); // 调试日志
|
||
|
||
// 用户消息
|
||
if (msg.query) {
|
||
newMessages.push({
|
||
sender: 'user',
|
||
avatar: '/static/yonghu.png',
|
||
content: msg.query,
|
||
image: '',
|
||
messageId: msg.id,
|
||
conversationId: msg.conversation_id
|
||
});
|
||
}
|
||
|
||
// AI消息
|
||
if (msg.answer) {
|
||
newMessages.push({
|
||
sender: 'ai',
|
||
avatar: '/static/AI.png',
|
||
content: msg.answer,
|
||
retrieverResources: msg.retriever_resources || [],
|
||
image: '',
|
||
messageId: 'ai-' + msg.id,
|
||
conversationId: msg.conversation_id
|
||
});
|
||
}
|
||
});
|
||
|
||
// 按创建时间排序
|
||
newMessages.sort((a, b) => {
|
||
return new Date(a.created_at) - new Date(b.created_at);
|
||
});
|
||
|
||
this.messages = newMessages;
|
||
|
||
if (newMessages.length > 0) {
|
||
this.conversation_id = newMessages[0].conversationId;
|
||
uni.setStorageSync('conversation_id', this.conversation_id);
|
||
this.earliestMessageId = newMessages[0].messageId;
|
||
}
|
||
|
||
this.hasMoreHistory = res.data.has_more || false;
|
||
this.scrollToBottom();
|
||
} else {
|
||
this.messages = this.getWelcomeMessage();
|
||
this.hasMoreHistory = false;
|
||
}
|
||
} catch (e) {
|
||
console.error('初始化聊天失败:', e);
|
||
this.$toast('加载历史记录失败');
|
||
this.initConversation();
|
||
}
|
||
|
||
// 确保初始化完成后滚动到底部
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
this.forceScrollToBottom();
|
||
}, 200);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 加载更多历史记录 - 基于ID分页
|
||
*/
|
||
async loadMoreHistory() {
|
||
if (this.isLoadingHistory || !this.hasMoreHistory || !this.conversation_id || !this
|
||
.earliestMessageId) {
|
||
return;
|
||
}
|
||
|
||
this.isLoadingHistory = true;
|
||
|
||
try {
|
||
// 记录当前滚动位置和高度
|
||
const currentScrollTop = this.scrollTop;
|
||
const query = uni.createSelectorQuery().in(this);
|
||
const currentScrollHeight = await new Promise(resolve => {
|
||
query.select('.message-list').boundingClientRect(rect => {
|
||
resolve(rect.height);
|
||
}).exec();
|
||
});
|
||
|
||
// 使用当前最早的消息ID作为分页参数
|
||
const beforeId = this.earliestMessageId;
|
||
|
||
const res = await getHistory({
|
||
user: this.user,
|
||
conversationId: this.conversation_id,
|
||
limit: 10,
|
||
beforeId: beforeId // 获取指定ID之前的记录
|
||
});
|
||
|
||
|
||
|
||
// 修正数据结构解析:使用res.data.data而不是res.data
|
||
if (res.code === 200 && res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
|
||
|
||
// 处理新获取的消息
|
||
// 在数据解析后添加调试日志
|
||
const newMessages = res.data.data.map(msg => {
|
||
// 根据控制台数据结构分析,修正字段映射
|
||
let sender, content;
|
||
|
||
if (msg.inputs?.user_name) {
|
||
// 用户消息
|
||
sender = 'user';
|
||
content = msg.query || msg.content || '';
|
||
} else {
|
||
// AI消息 - 根据实际数据结构调整字段映射
|
||
sender = 'ai';
|
||
content = msg.answer || msg.content || msg.reply || '';
|
||
}
|
||
|
||
const message = {
|
||
sender: sender,
|
||
avatar: sender === 'user' ? '/static/yonghu.png' : '/static/AI.png',
|
||
content: content,
|
||
retrieverResources: msg.retriever_resources || [],
|
||
image: '',
|
||
messageId: msg.id,
|
||
conversationId: msg.conversation_id
|
||
};
|
||
|
||
return message;
|
||
});
|
||
|
||
// 按ID排序
|
||
newMessages.sort((a, b) => {
|
||
const aId = parseInt(a.messageId) || 0;
|
||
const bId = parseInt(b.messageId) || 0;
|
||
return aId - bId;
|
||
});
|
||
|
||
// 插入到现有消息的前面
|
||
this.insertMessagesAtFront(newMessages);
|
||
|
||
// 使用has_more字段判断是否还有更多数据
|
||
this.hasMoreHistory = res.data.has_more || false;
|
||
|
||
// 保持滚动位置,避免跳变
|
||
this.$nextTick(() => {
|
||
query.select('.message-list').boundingClientRect(rect => {
|
||
if (rect) {
|
||
const heightDiff = rect.height - currentScrollHeight;
|
||
this.scrollTop = currentScrollTop + heightDiff;
|
||
}
|
||
}).exec();
|
||
});
|
||
} else {
|
||
this.hasMoreHistory = false;
|
||
}
|
||
} catch (error) {
|
||
console.error('加载历史记录失败:', error);
|
||
this.$toast('加载历史记录失败');
|
||
} finally {
|
||
this.isLoadingHistory = false;
|
||
}
|
||
|
||
return Promise.resolve();
|
||
},
|
||
|
||
/**
|
||
* 按ID顺序插入新消息到前面
|
||
* @param {Array} newMessages - 新消息数组
|
||
*/
|
||
insertMessagesAtFront(newMessages) {
|
||
if (!newMessages || newMessages.length === 0) return;
|
||
|
||
// 确保新消息有正确的messageId
|
||
const processedMessages = newMessages.map(msg => ({
|
||
...msg,
|
||
messageId: msg.messageId || msg.id || 'history-' + Date.now() + '-' + Math.random()
|
||
}));
|
||
|
||
// 按ID排序,确保正确的时间顺序
|
||
processedMessages.sort((a, b) => {
|
||
const aId = parseInt(a.messageId.replace(/\D/g, '')) || 0;
|
||
const bId = parseInt(b.messageId.replace(/\D/g, '')) || 0;
|
||
return aId - bId;
|
||
});
|
||
|
||
// 插入到消息列表前面
|
||
this.messages = [...processedMessages, ...this.messages];
|
||
|
||
// 更新最早消息ID
|
||
if (processedMessages.length > 0) {
|
||
this.earliestMessageId = processedMessages[0].messageId;
|
||
}
|
||
|
||
// 保存到本地存储
|
||
this.saveMessagesToLocal();
|
||
},
|
||
|
||
/**
|
||
* 初始化新对话
|
||
*/
|
||
initConversation() {
|
||
this.conversation_id = null;
|
||
this.messages = this.getWelcomeMessage();
|
||
this.hasMoreHistory = false; // 新对话没有历史记录
|
||
this.saveMessagesToLocal();
|
||
},
|
||
|
||
/**
|
||
* 保存消息到本地存储
|
||
*/
|
||
saveMessagesToLocal() {
|
||
if (!this.user) return;
|
||
|
||
const storageKey = `chatMessages_${this.user}_${this.conversation_id || 'default'}`;
|
||
|
||
// 限制本地存储的消息数量,避免占用过多空间
|
||
const maxMessages = 100;
|
||
const messagesToSave = this.messages.length > maxMessages ?
|
||
this.messages.slice(0, maxMessages) :
|
||
this.messages;
|
||
|
||
uni.setStorageSync(storageKey, messagesToSave);
|
||
},
|
||
|
||
/**
|
||
* 欢迎消息
|
||
* @returns {Array} 欢迎消息数组
|
||
*/
|
||
getWelcomeMessage() {
|
||
return [{
|
||
sender: 'ai',
|
||
avatar: '/static/AI.png',
|
||
content: '你好!我是您的 AI 小助手,有什么可以帮您?😊',
|
||
retrieverResources: [],
|
||
image: '',
|
||
messageId: 'welcome-' + Date.now().toString()
|
||
}];
|
||
},
|
||
|
||
/**
|
||
* 基于SSE的发送消息实现
|
||
*/
|
||
async sendMessage() {
|
||
const msg = this.inputMessage.trim();
|
||
if (!msg || this.sending) return;
|
||
this.sending = true;
|
||
|
||
// 添加用户消息
|
||
this.messages.push({
|
||
sender: 'user',
|
||
avatar: '/static/yonghu.png',
|
||
content: msg,
|
||
image: '',
|
||
messageId: Date.now().toString()
|
||
});
|
||
this.inputMessage = '';
|
||
this.saveMessagesToLocal(); // 保存用户消息
|
||
|
||
// 立即滚动到底部显示用户消息
|
||
this.forceScrollToBottom();
|
||
|
||
// 添加AI消息占位
|
||
const aiIdx = this.messages.push({
|
||
sender: 'ai',
|
||
avatar: '/static/AI.png',
|
||
content: '<span class="loading-text">正在思考...</span>',
|
||
retrieverResources: [],
|
||
image: '',
|
||
messageId: 'pending-' + Date.now().toString()
|
||
}) - 1;
|
||
this.$set(this.showSingleReference, aiIdx, {});
|
||
this.scrollToBottom();
|
||
|
||
// 取消之前的请求
|
||
if (this.currentCancel) {
|
||
this.currentCancel('新消息发送,终止旧连接');
|
||
}
|
||
|
||
try {
|
||
const {
|
||
stream,
|
||
cancel
|
||
} = createChatStream({
|
||
conversationId: this.conversation_id,
|
||
prompt: msg,
|
||
user: this.user,
|
||
userId: this.userId,
|
||
userName: this.userName
|
||
});
|
||
|
||
this.currentCancel = cancel;
|
||
const {
|
||
reader,
|
||
decoder
|
||
} = await stream;
|
||
|
||
let buffer = '';
|
||
// 流式处理响应
|
||
while (true) {
|
||
const {
|
||
done,
|
||
value
|
||
} = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, {
|
||
stream: true
|
||
});
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
let jsonStr = line.replace(/^data:/, '').trim();
|
||
const data = JSON.parse(jsonStr);
|
||
|
||
// 更新消息内容
|
||
if (data.event === 'message' && data.answer) {
|
||
const currentContent = this.messages[aiIdx].content;
|
||
const newContent = (currentContent.includes('loading-text') ? '' :
|
||
currentContent) + data.answer;
|
||
this.$set(this.messages[aiIdx], 'content', newContent);
|
||
|
||
// 每次更新内容后立即滚动到底部
|
||
this.scrollToBottom(true); // 传入true表示是流式响应中的滚动
|
||
this.saveMessagesToLocal();
|
||
}
|
||
|
||
// 处理结束消息
|
||
if (data.event === 'message_end') {
|
||
if (data.conversation_id) {
|
||
this.conversation_id = data.conversation_id;
|
||
uni.setStorageSync('conversation_id', this.conversation_id);
|
||
}
|
||
if (data.metadata?.retriever_resources) {
|
||
this.$set(this.messages[aiIdx], 'retrieverResources', data.metadata
|
||
.retriever_resources);
|
||
}
|
||
if (data.message_id) {
|
||
this.$set(this.messages[aiIdx], 'messageId', data.message_id);
|
||
}
|
||
|
||
// 最终完成后再次确保滚动到底部
|
||
this.forceScrollToBottom();
|
||
}
|
||
} catch (e) {
|
||
console.warn('JSON解析失败:', line, e);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('流式请求失败:', e);
|
||
this.$set(this.messages[aiIdx], 'content', 'AI回复失败: ' + (e.message || '网络错误'));
|
||
} finally {
|
||
this.sending = false;
|
||
this.currentCancel = null;
|
||
|
||
// 确保AI消息有有效的messageId
|
||
if (this.messages[aiIdx].messageId.startsWith('pending-')) {
|
||
this.$set(this.messages[aiIdx], 'messageId', 'ai-' + Date.now().toString());
|
||
}
|
||
|
||
// 保存消息到本地存储
|
||
this.saveMessagesToLocal();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 滚动到底部
|
||
* @param {boolean} isStreaming - 是否是流式响应中的滚动
|
||
*/
|
||
// 修改原有scrollToBottom方法
|
||
scrollToBottom(isStreaming = false) {
|
||
const now = Date.now();
|
||
if (now - this.lastScrollTime < this.scrollDebounce && isStreaming) {
|
||
return;
|
||
}
|
||
this.lastScrollTime = now;
|
||
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
try {
|
||
const query = uni.createSelectorQuery().in(this);
|
||
query.select('.message-list').scrollOffset(res => {
|
||
if (res && res.scrollHeight) {
|
||
// 使用大值确保滚动到最底部
|
||
this.scrollTop = res.scrollHeight + 1000;
|
||
} else {
|
||
// 备用方案
|
||
this.scrollTop = 99999;
|
||
}
|
||
}).exec();
|
||
} catch (error) {
|
||
console.error('滚动失败:', error);
|
||
// 备用方案
|
||
this.scrollTop = 99999;
|
||
}
|
||
}, isStreaming ? 50 : 100);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 切换单个引用的显示状态
|
||
* @param {number} msgIdx - 消息索引
|
||
* @param {number} refIdx - 引用索引
|
||
*/
|
||
toggleSingleReference(msgIdx, refIdx) {
|
||
if (!this.showSingleReference[msgIdx]) this.$set(this.showSingleReference, msgIdx, {});
|
||
const cur = this.showSingleReference[msgIdx][refIdx];
|
||
this.$set(this.showSingleReference[msgIdx], refIdx, !cur);
|
||
},
|
||
|
||
/**
|
||
* 点赞处理
|
||
* @param {string} id - 消息ID
|
||
*/
|
||
handleThumbUpClick(id) {
|
||
if (!id) {
|
||
this.$toast('消息ID不存在', 'error');
|
||
return;
|
||
}
|
||
|
||
sendFeedback({
|
||
messageId: id,
|
||
action: 1,
|
||
user: this.user
|
||
}).then(res => {
|
||
if (res.code === 200) {
|
||
this.$toast('点赞成功', 'success');
|
||
} else {
|
||
this.$toast(res.msg || '点赞失败', 'error');
|
||
}
|
||
}).catch(err => {
|
||
console.error('点赞失败:', err);
|
||
this.$toast('点赞失败', 'error');
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 点踩处理
|
||
* @param {string} id - 消息ID
|
||
*/
|
||
handleThumbDownClick(id) {
|
||
if (!id) {
|
||
this.$toast('消息ID不存在', 'error');
|
||
return;
|
||
}
|
||
|
||
sendFeedback({
|
||
messageId: id,
|
||
action: 0,
|
||
user: this.user
|
||
}).then(res => {
|
||
if (res.code === 200) {
|
||
this.$toast('已反馈', 'success');
|
||
} else {
|
||
this.$toast(res.msg || '反馈失败', 'error');
|
||
}
|
||
}).catch(err => {
|
||
console.error('反馈失败:', err);
|
||
this.$toast('反馈失败', 'error');
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 滚动事件监听
|
||
* @param {Object} e - 事件对象
|
||
*/
|
||
onScroll(e) {
|
||
this.scrollHeight = e.detail.scrollHeight;
|
||
this.lastUserScrollTime = Date.now();
|
||
},
|
||
|
||
/**
|
||
* 智能滚动到顶部
|
||
* 如果用户在3秒内没有滚动,则自动滚动到顶部
|
||
*/
|
||
smartScrollToTop() {
|
||
// 如果用户在3秒内没有滚动,则自动滚动到顶部
|
||
if (Date.now() - this.lastUserScrollTime > 3000) {
|
||
this.forceScrollToTop();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 选择图片并上传
|
||
*/
|
||
selectImage() {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: res => {
|
||
const temp = res.tempFilePaths[0];
|
||
uni.uploadFile({
|
||
url: '/aitutor/aichat/files/upload',
|
||
filePath: temp,
|
||
name: 'file',
|
||
formData: {
|
||
user: this.user,
|
||
userId: this.userId,
|
||
userName: this.userName
|
||
},
|
||
success: ({
|
||
data
|
||
}) => {
|
||
try {
|
||
const {
|
||
url
|
||
} = JSON.parse(data);
|
||
if (url) {
|
||
this.messages.push({
|
||
sender: 'user',
|
||
avatar: '/static/yonghu.png',
|
||
content: '',
|
||
image: url,
|
||
messageId: Date.now().toString()
|
||
});
|
||
this.scrollToBottom();
|
||
this.saveMessagesToLocal(); // 保存图片消息
|
||
}
|
||
} catch (e) {
|
||
this.$toast('上传解析失败');
|
||
}
|
||
},
|
||
fail: () => this.$toast('上传失败')
|
||
});
|
||
},
|
||
fail: () => this.$toast('选择图片失败')
|
||
});
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 引入全局样式 */
|
||
@import '@/static/scss/ai_index.css';
|
||
</style> |