Files
zhxg_app/pages/aiChat/ai_index.vue

943 lines
26 KiB
Vue
Raw Normal View History

2025-08-13 09:19:28 +08:00
<template>
<view class="chat-container">
<!-- 自定义导航栏 -->
2025-08-14 11:42:34 +08:00
<!-- <view class="status-bar-placeholder"></view>
2025-08-13 09:19:28 +08:00
<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>
2025-08-14 11:42:34 +08:00
</view> -->
2025-08-13 09:19:28 +08:00
<!-- 消息列表 -->
2025-08-15 16:26:06 +08:00
<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">
2025-08-14 00:36:04 +08:00
<!-- 加载提示 -->
<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">
2025-08-13 09:19:28 +08:00
<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>
2025-08-14 00:36:04 +08:00
2025-08-13 09:19:28 +08:00
<!-- AI 特有内容 -->
<view v-if="item.sender === 'ai'" class="ai-hint">
<!-- 引用来源部分 -->
<view v-if="item.retrieverResources && item.retrieverResources.length"
class="reference-section">
2025-08-14 00:36:04 +08:00
<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>
2025-08-13 09:19:28 +08:00
</view>
</view>
</view>
2025-08-14 00:36:04 +08:00
2025-08-13 09:19:28 +08:00
<!-- 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" />
<!-- 添加图片按钮 -->
2025-08-14 11:42:34 +08:00
<!-- <img src="/static/add.svg" class="add-icon" @click="selectImage" /> -->
2025-08-13 09:19:28 +08:00
<!-- 发送消息按钮 -->
2025-08-14 11:42:34 +08:00
<button class="send-button" @click="sendMessage">发送</button>
2025-08-13 09:19:28 +08:00
</view>
<!-- 悬浮按钮固定在右下角 -->
2025-08-14 11:42:34 +08:00
<!-- <view class="ai-hover" @click="goHome">
2025-08-13 09:19:28 +08:00
<view class="ai-hover-content">
<text class="ai-hover-text">AI</text>
</view>
2025-08-14 11:42:34 +08:00
</view> -->
2025-08-13 09:19:28 +08:00
</view>
</template>
<script>
2025-08-14 09:51:55 +08:00
/* ========== 依赖 ========== */
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, // 添加这个字段用于智能滚动
};
},
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
/* ---------- 生命周期 ---------- */
onLoad() {
if (!this.user) {
this.$toast('请先登录');
setTimeout(() => uni.navigateTo({
url: '/pages/login/index'
}), 1500);
return;
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
this.conversation_id = uni.getStorageSync('conversation_id') || null;
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 先初始化消息,再滚动到底部
this.initChat().then(() => {
// 使用三重确保策略处理不同设备的渲染差异
const scrollWithRetry = (attempt = 0) => {
this.$nextTick(() => {
2025-08-14 00:36:04 +08:00
setTimeout(() => {
2025-08-14 09:51:55 +08:00
this.forceScrollToBottom();
// 第二次尝试处理iOS等需要额外触发的设备
2025-08-14 00:36:04 +08:00
setTimeout(() => {
2025-08-14 11:42:34 +08:00
this.forceScrollToBottom(); // 确保滚动到底部
2025-08-14 09:51:55 +08:00
// 第三次确保(针对动态内容加载的情况)
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);
2025-08-14 00:36:04 +08:00
}, 100);
2025-08-14 09:51:55 +08:00
});
};
// 初始触发
scrollWithRetry();
}).catch(e => {
console.error('初始化失败:', e);
// 失败时仍尝试滚动
this.$nextTick(() => {
setTimeout(() => this.forceScrollToBottom(), 300);
});
2025-08-14 00:36:04 +08:00
});
2025-08-13 09:19:28 +08:00
},
2025-08-14 00:36:04 +08:00
2025-08-14 11:42:34 +08:00
/**
* 页面显示时触发
*/
onShow() {
// 页面显示时确保滚动到底部
this.$nextTick(() => {
setTimeout(() => {
console.log('onShow: 尝试滚动到底部');
this.forceScrollToBottom();
}, 200);
});
},
2025-08-14 09:51:55 +08:00
/* ---------- 方法 ---------- */
methods: {
/**
* 返回首页
*/
goHome() {
uni.reLaunch({
url: '/pages/index/index'
});
},
/**
* 强制滚动到底部
2025-08-14 11:42:34 +08:00
* @param {number} offset - 额外的偏移量默认为0
2025-08-14 09:51:55 +08:00
*/
2025-08-14 11:42:34 +08:00
forceScrollToBottom(offset = 0) {
console.log('forceScrollToBottom: 开始执行滚动');
2025-08-14 09:51:55 +08:00
this.$nextTick(() => {
try {
const query = uni.createSelectorQuery().in(this);
2025-08-14 11:42:34 +08:00
query.select('.message-list').scrollOffset(res => {
if (res) {
console.log('forceScrollToBottom: 获取到scrollOffset', res);
console.log('forceScrollToBottom: 当前scrollTop', this.scrollTop);
2025-08-15 16:26:06 +08:00
2025-08-14 11:42:34 +08:00
// 使用scrollHeight如果没有则使用一个大值
const scrollHeight = res.scrollHeight || 99999;
const targetScrollTop = scrollHeight + 1000;
2025-08-15 16:26:06 +08:00
2025-08-14 11:42:34 +08:00
// 先重置scrollTop然后设置到底部
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = targetScrollTop;
2025-08-15 16:26:06 +08:00
console.log('forceScrollToBottom: 设置scrollTop为',
targetScrollTop);
2025-08-14 11:42:34 +08:00
// 延迟再次确保滚动到底部
2025-08-14 09:51:55 +08:00
setTimeout(() => {
2025-08-15 16:26:06 +08:00
const finalScrollTop = (res.scrollHeight ||
99999) + 1000;
2025-08-14 11:42:34 +08:00
this.scrollTop = finalScrollTop;
2025-08-15 16:26:06 +08:00
console.log('forceScrollToBottom: 延迟设置scrollTop完成',
finalScrollTop);
2025-08-14 11:42:34 +08:00
}, 100);
});
} else {
console.log('forceScrollToBottom: 未获取到scrollOffset');
// 直接使用备用方案
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = 99999;
console.log('forceScrollToBottom: 使用备用方案1');
});
2025-08-14 09:51:55 +08:00
}
}).exec();
} catch (error) {
console.error('滚动到底部失败:', error);
2025-08-14 11:42:34 +08:00
// 备用方案:直接设置一个很大的值
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = 99999;
console.log('forceScrollToBottom: 使用备用方案2');
});
2025-08-14 09:51:55 +08:00
}
});
},
// 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);
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 确保滚动到底部
this.$nextTick(() => {
setTimeout(() => {
this.scrollToBottom();
}, 300);
});
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
this.saveMessagesToLocal(); // 保存到本地
this.$toast('已加载历史对话', 'success');
},
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
/**
* 初始化聊天 - 从接口获取历史记录
*/
async initChat() {
if (!this.user) return this.initConversation();
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 重置状态
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
});
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
console.log('API响应数据:', res); // 调试日志
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
if (res.code === 200 && res.data && Array.isArray(res.data.data)) {
// 新消息数组
const newMessages = [];
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 处理每条消息
res.data.data.forEach(msg => {
console.log('处理单条消息:', msg); // 调试日志
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 用户消息
if (msg.query) {
newMessages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: msg.query,
image: '',
messageId: msg.id,
conversationId: msg.conversation_id
});
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 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
});
}
});
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 按创建时间排序
newMessages.sort((a, b) => {
return new Date(a.created_at) - new Date(b.created_at);
});
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
this.messages = newMessages;
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
if (newMessages.length > 0) {
this.conversation_id = newMessages[0].conversationId;
uni.setStorageSync('conversation_id', this.conversation_id);
this.earliestMessageId = newMessages[0].messageId;
2025-08-13 09:19:28 +08:00
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
this.hasMoreHistory = res.data.has_more || false;
this.scrollToBottom();
} else {
this.messages = this.getWelcomeMessage();
this.hasMoreHistory = false;
2025-08-13 09:19:28 +08:00
}
2025-08-14 09:51:55 +08:00
} catch (e) {
console.error('初始化聊天失败:', e);
this.$toast('加载历史记录失败');
this.initConversation();
2025-08-13 09:19:28 +08:00
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 确保初始化完成后滚动到底部
this.$nextTick(() => {
setTimeout(() => {
this.forceScrollToBottom();
}, 200);
2025-08-14 00:36:04 +08:00
});
2025-08-14 09:51:55 +08:00
},
/**
* 加载更多历史记录 - 基于ID分页
*/
async loadMoreHistory() {
if (this.isLoadingHistory || !this.hasMoreHistory || !this.conversation_id || !this
.earliestMessageId) {
return;
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
this.isLoadingHistory = true;
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
try {
// 记录当前滚动位置和高度
const currentScrollTop = this.scrollTop;
const query = uni.createSelectorQuery().in(this);
const currentScrollHeight = await new Promise(resolve => {
2025-08-14 00:36:04 +08:00
query.select('.message-list').boundingClientRect(rect => {
2025-08-14 09:51:55 +08:00
resolve(rect.height);
2025-08-14 00:36:04 +08:00
}).exec();
});
2025-08-14 09:51:55 +08:00
// 使用当前最早的消息ID作为分页参数
const beforeId = this.earliestMessageId;
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
const res = await getHistory({
user: this.user,
conversationId: this.conversation_id,
limit: 10,
beforeId: beforeId // 获取指定ID之前的记录
});
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 修正数据结构解析使用res.data.data而不是res.data
if (res.code === 200 && res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 处理新获取的消息
// 在数据解析后添加调试日志
const newMessages = res.data.data.map(msg => {
// 根据控制台数据结构分析,修正字段映射
let sender, content;
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
if (msg.inputs?.user_name) {
// 用户消息
sender = 'user';
content = msg.query || msg.content || '';
} else {
// AI消息 - 根据实际数据结构调整字段映射
sender = 'ai';
content = msg.answer || msg.content || msg.reply || '';
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
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;
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
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;
});
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 插入到消息列表前面
this.messages = [...processedMessages, ...this.messages];
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 更新最早消息ID
if (processedMessages.length > 0) {
this.earliestMessageId = processedMessages[0].messageId;
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
// 保存到本地存储
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()
2025-08-14 00:36:04 +08:00
});
2025-08-14 09:51:55 +08:00
this.inputMessage = '';
this.saveMessagesToLocal(); // 保存用户消息
2025-08-15 16:26:06 +08:00
2025-08-14 11:42:34 +08:00
// 立即滚动到底部显示用户消息
this.forceScrollToBottom();
2025-08-14 09:51:55 +08:00
// 添加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('新消息发送,终止旧连接');
}
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
try {
2025-08-13 09:19:28 +08:00
const {
2025-08-14 09:51:55 +08:00
stream,
cancel
} = createChatStream({
conversationId: this.conversation_id,
prompt: msg,
user: this.user,
userId: this.userId,
userName: this.userName
2025-08-14 00:36:04 +08:00
});
2025-08-14 09:51:55 +08:00
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();
2025-08-14 00:36:04 +08:00
}
2025-08-14 09:51:55 +08:00
// 处理结束消息
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);
}
// 最终完成后再次确保滚动到底部
2025-08-14 11:42:34 +08:00
this.forceScrollToBottom();
2025-08-14 09:51:55 +08:00
}
} catch (e) {
console.warn('JSON解析失败:', line, e);
2025-08-13 09:19:28 +08:00
}
}
}
2025-08-14 09:51:55 +08:00
} 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();
2025-08-13 09:19:28 +08:00
}
2025-08-14 09:51:55 +08:00
},
/**
* 滚动到底部
* @param {boolean} isStreaming - 是否是流式响应中的滚动
*/
// 修改原有scrollToBottom方法
scrollToBottom(isStreaming = false) {
const now = Date.now();
if (now - this.lastScrollTime < this.scrollDebounce && isStreaming) {
return;
2025-08-14 00:36:04 +08:00
}
2025-08-14 09:51:55 +08:00
this.lastScrollTime = now;
2025-08-14 00:36:04 +08:00
2025-08-14 09:51:55 +08:00
this.$nextTick(() => {
setTimeout(() => {
2025-08-14 11:42:34 +08:00
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;
}
2025-08-14 09:51:55 +08:00
}, 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;
2025-08-14 00:36:04 +08:00
}
2025-08-14 09:51:55 +08:00
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;
2025-08-14 00:36:04 +08:00
}
2025-08-14 09:51:55 +08:00
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('上传解析失败');
2025-08-14 00:36:04 +08:00
}
2025-08-14 09:51:55 +08:00
},
fail: () => this.$toast('上传失败')
});
},
fail: () => this.$toast('选择图片失败')
});
}
2025-08-13 09:19:28 +08:00
}
2025-08-14 09:51:55 +08:00
};
2025-08-13 09:19:28 +08:00
</script>
<style scoped>
2025-08-14 09:51:55 +08:00
/* 引入全局样式 */
@import '@/static/scss/ai_index.css';
2025-08-13 09:19:28 +08:00
</style>