Files
zhxg_app/pages/aiChat/ai_index.vue
2025-08-14 11:42:34 +08:00

940 lines
26 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">
<!-- 自定义导航栏 -->
<!-- <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>