Files
zhxg_app/pages/aiChat/simple_chat.vue

947 lines
23 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">
<!-- 消息列表 -->
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" :scroll-with-animation="scrollWithAnimation"
:scroll-into-view="scrollIntoView" @scroll="onScroll">
<!-- 加载提示 -->
<view v-if="isLoadingHistory" class="loading-history">
<text>正在加载历史记录...</text>
</view>
<!-- 没有更多历史记录提示 -->
<view v-if="!hasMoreHistory && messages.length > 0" class="no-more-history">
<text>没有更多历史记录了</text>
</view>
<!-- 消息列表 -->
<view v-for="(item, index) in messages" :key="item.messageId || index" :id="'msg-' + item.messageId"
:class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
<!-- 头像 -->
<image class="avatar" :src="item.avatar"></image>
<!-- 消息内容 -->
<view class="message-content">
<!-- AI消息支持Markdown渲染 -->
<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>
<!-- 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="(groupedRef, groupIdx) in getGroupedReferences(item.retrieverResources)" :key="groupIdx" class="reference-item-wrapper">
<!-- 可点击的文档名 -->
<text class="doc-name-link" @click="toggleSingleReference(index, groupIdx)">
{{ groupedRef.document_name }}
</text>
<!-- 展开的详情仅当前项 -->
<view v-if="showSingleReference[index] && showSingleReference[index][groupIdx]" class="reference-details-item">
<!-- 分段显示内容 -->
<view v-for="(ref, refIdx) in groupedRef.references" :key="refIdx" class="reference-segment">
<text class="reference-meta">{{ ref.name }}{{ ref.document_name }}</text>
<text class="reference-content" v-if="ref.content">{{ ref.content }}</text>
<view v-if="refIdx < groupedRef.references.length - 1" class="reference-divider"></view>
</view>
</view>
</view>
</view>
<!-- AI操作区域 -->
<view class="ai-actions">
<text class="ai-text">AI回答也可能会犯错请核查重要信息</text>
<view class="icon-group">
<!-- 点赞图标 -->
<u-icon
:name="item.feedback && item.feedback.rating === 'like' ? 'thumb-up-fill' : 'thumb-up'"
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'like' }]"
@click="handleThumbUp(item.messageId, index)"
size="20"
:color="item.feedback && item.feedback.rating === 'like' ? '#007aff' : '#666'"></u-icon>
<!-- 点踩图标 -->
<u-icon
:name="item.feedback && item.feedback.rating === 'dislike' ? 'thumb-down-fill' : 'thumb-down'"
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'dislike' }]"
@click="handleThumbDown(item.messageId, index)"
size="20"
:color="item.feedback && item.feedback.rating === 'dislike' ? '#ff3b30' : '#666'"></u-icon>
<!-- 原有的img方式已注释
<img src="/static/good.svg"
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'like' }]"
@click="handleThumbUp(item.messageId, index)" />
<img src="/static/tread.svg"
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'dislike' }]"
@click="handleThumbDown(item.messageId, index)" />
-->
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 输入框区域 -->
<view class="input-container">
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send"
:disabled="sending" />
<button class="send-button" @click="sendMessage" :disabled="sending || !inputMessage.trim()">
{{ sending ? '发送中...' : '发送' }}
</button>
</view>
</view>
</template>
<script>
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';
// Markdown配置
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
});
// DOMPurify安全配置
DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
});
export default {
data() {
return {
// 基础数据
inputMessage: '',
messages: [],
scrollTop: 0,
sending: false,
// 滚动控制
scrollWithAnimation: true,
scrollIntoView: '',
lastScrollTop: 0,
loadThreshold: 200, // 距离顶部200px时开始加载
loadDebounceTimer: null, // 加载防抖定时器
// 用户信息
user: uni.getStorageSync('stuNo') || '',
userId: uni.getStorageSync('stuId') || '',
userName: uni.getStorageSync('stuName') || '',
// 对话相关
conversation_id: null,
currentCancel: null,
// 分页加载
isLoadingHistory: false,
hasMoreHistory: true,
earliestMessageId: null,
// 引用信息展示控制
showSingleReference: {}
};
},
onLoad() {
// 检查登录状态
if (!this.user) {
this.showToast('请先登录');
setTimeout(() => {
uni.navigateTo({ url: '/pages/login/index' });
}, 1500);
return;
}
// 获取对话ID
this.conversation_id = uni.getStorageSync('conversation_id') || null;
// 初始化聊天
this.initChat();
},
onUnload() {
// 清理防抖定时器
if (this.loadDebounceTimer) {
clearTimeout(this.loadDebounceTimer);
this.loadDebounceTimer = null;
}
},
methods: {
/**
* 初始化聊天 - 获取历史记录
*/
async initChat() {
try {
const res = await getHistory({
user: this.user,
conversationId: this.conversation_id || '',
limit: 10
});
if (res.code === 200 && res.data && Array.isArray(res.data.data)) {
const newMessages = [];
// 处理历史消息
res.data.data.forEach(msg => {
// 用户消息
if (msg.query) {
newMessages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: msg.query,
messageId: msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at
});
}
// AI消息
if (msg.answer) {
newMessages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: msg.answer,
messageId: 'ai-' + msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at,
feedback: msg.feedback || null, // 添加反馈状态
retrieverResources: msg.retriever_resources || [] // 添加引用资源
});
}
});
// 按时间排序(从旧到新)
newMessages.sort((a, b) => {
return a.created_at - b.created_at;
});
this.messages = newMessages;
if (newMessages.length > 0) {
this.conversation_id = newMessages[0].conversationId;
uni.setStorageSync('conversation_id', this.conversation_id);
// 设置最早的用户消息ID用于分页
const userMessages = newMessages.filter(msg => msg.sender === 'user');
if (userMessages.length > 0) {
this.earliestMessageId = userMessages[0].messageId;
} else {
// 如果没有用户消息使用第一个消息的原始ID
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '');
}
}
this.hasMoreHistory = res.data.has_more || false;
} else {
// 没有历史记录,显示欢迎消息
this.messages = [{
sender: 'ai',
avatar: '/static/AI.png',
content: '你好我是智水AI辅导员有什么可以帮助你的吗',
messageId: 'welcome-' + Date.now()
}];
this.hasMoreHistory = false;
}
// 滚动到底部
this.$nextTick(() => {
setTimeout(() => {
this.scrollToBottom();
// 初始化滚动位置记录
this.lastScrollTop = 99999; // 设置为底部位置
}, 200);
});
} catch (error) {
console.error('初始化聊天失败:', error);
this.showToast('加载历史记录失败');
// 显示欢迎消息
this.messages = [{
sender: 'ai',
avatar: '/static/AI.png',
content: '你好我是智水AI辅导员有什么可以帮助你的吗',
messageId: 'welcome-' + Date.now()
}];
}
},
/**
* 加载更多历史记录
*/
async loadMoreHistory() {
if (this.isLoadingHistory || !this.hasMoreHistory || !this.conversation_id || !this.earliestMessageId) {
return;
}
this.isLoadingHistory = true;
try {
// 记录当前第一个可见消息的ID作为锚点
const anchorMessageId = this.messages.length > 0 ? this.messages[0].messageId : null;
const res = await getHistory({
user: this.user,
conversationId: this.conversation_id,
limit: 10,
beforeId: this.earliestMessageId
});
if (res.code === 200 && res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
const newMessages = [];
// 处理新获取的消息
res.data.data.forEach(msg => {
// 用户消息
if (msg.query) {
newMessages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: msg.query,
messageId: msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at
});
}
// AI消息
if (msg.answer) {
newMessages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: msg.answer,
messageId: 'ai-' + msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at,
feedback: msg.feedback || null, // 添加反馈状态
retrieverResources: msg.retriever_resources || [] // 添加引用资源
});
}
});
// 按时间排序(从旧到新)
newMessages.sort((a, b) => {
return a.created_at - b.created_at;
});
// 禁用滚动动画,避免闪烁
this.scrollWithAnimation = false;
// 插入到现有消息前面
this.messages = [...newMessages, ...this.messages];
// 更新最早消息ID为新获取消息中最早的那个用于下次分页
if (newMessages.length > 0) {
// 找到最早的用户消息ID不带'ai-'前缀的)
const userMessages = newMessages.filter(msg => msg.sender === 'user');
if (userMessages.length > 0) {
this.earliestMessageId = userMessages[0].messageId;
} else {
// 如果没有用户消息使用第一个消息的原始ID
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '');
}
}
this.hasMoreHistory = res.data.has_more || false;
// 使用scroll-into-view定位到锚点消息实现无闪烁固定位置
if (anchorMessageId) {
this.$nextTick(() => {
// 直接滚动到锚点消息位置
this.scrollIntoView = 'msg-' + anchorMessageId;
// 短暂延迟后重新启用滚动动画
setTimeout(() => {
this.scrollWithAnimation = true;
this.scrollIntoView = ''; // 清空scroll-into-view
}, 100);
});
}
} else {
this.hasMoreHistory = false;
}
} catch (error) {
console.error('加载历史记录失败:', error);
this.showToast('加载历史记录失败');
} finally {
this.isLoadingHistory = false;
}
},
/**
* 发送消息
*/
async sendMessage() {
const message = this.inputMessage.trim();
if (!message || this.sending) return;
this.sending = true;
// 添加用户消息
this.messages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: message,
messageId: Date.now().toString()
});
this.inputMessage = '';
this.scrollToBottom();
// 添加AI消息占位
const aiIndex = this.messages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: '正在思考...',
messageId: 'pending-' + Date.now().toString(),
feedback: null // 初始化反馈状态
}) - 1;
this.scrollToBottom();
// 取消之前的请求
if (this.currentCancel) {
this.currentCancel('新消息发送,终止旧连接');
}
try {
const { stream, cancel } = createChatStream({
conversationId: this.conversation_id,
prompt: message,
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[aiIndex].content;
const newContent = (currentContent === '正在思考...' ? '' : currentContent) + data.answer;
this.$set(this.messages[aiIndex], 'content', newContent);
// 流式响应中滚动到底部
this.scrollToBottom();
}
// 处理结束消息
if (data.event === 'message_end') {
if (data.conversation_id) {
this.conversation_id = data.conversation_id;
uni.setStorageSync('conversation_id', this.conversation_id);
}
if (data.message_id) {
this.$set(this.messages[aiIndex], 'messageId', data.message_id);
// 确保新消息有feedback字段
if (!this.messages[aiIndex].feedback) {
this.$set(this.messages[aiIndex], 'feedback', { rating: null });
}
}
// 最终滚动到底部
this.scrollToBottom();
}
} catch (e) {
console.warn('JSON解析失败:', line, e);
}
}
}
} catch (error) {
console.error('发送消息失败:', error);
this.showToast('发送失败,请重试');
// 更新AI消息为错误提示
this.$set(this.messages[aiIndex], 'content', '抱歉,发送失败了,请重试。');
} finally {
this.sending = false;
this.currentCancel = null;
}
},
/**
* 滚动到底部
*/
scrollToBottom() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list').scrollOffset(res => {
if (res) {
const scrollHeight = res.scrollHeight || 99999;
this.scrollTop = scrollHeight + 1000;
}
}).exec();
});
},
/**
* 滚动事件监听
*/
onScroll(e) {
const scrollTop = e.detail.scrollTop;
// 检查是否需要加载历史记录
// 当滚动到距离顶部阈值范围内且向上滚动时触发加载
if (scrollTop <= this.loadThreshold &&
scrollTop < this.lastScrollTop &&
this.hasMoreHistory &&
!this.isLoadingHistory) {
// 清除之前的防抖定时器
if (this.loadDebounceTimer) {
clearTimeout(this.loadDebounceTimer);
}
// 设置防抖延迟加载
this.loadDebounceTimer = setTimeout(() => {
if (this.hasMoreHistory && !this.isLoadingHistory) {
this.loadMoreHistory();
}
}, 300); // 300ms防抖延迟
}
// 更新上次滚动位置
this.lastScrollTop = scrollTop;
},
/**
* 渲染Markdown内容
*/
renderMarkdown(text) {
return DOMPurify.sanitize(md.render(text || ''));
},
/**
* 点赞
*/
handleThumbUp(messageId, index) {
if (!messageId) {
this.showToast('消息ID不存在');
return;
}
// 去除AI消息ID中的'ai-'前缀
const actualMessageId = messageId.replace('ai-', '');
sendFeedback({
messageId: actualMessageId,
action: 1,
user: this.user
}).then(res => {
if (res.code === 200) {
this.showToast('点赞成功', 'success');
// 更新本地消息的反馈状态
this.$set(this.messages[index], 'feedback', { rating: 'like' });
} else {
this.showToast(res.msg || '点赞失败');
}
}).catch(err => {
console.error('点赞失败:', err);
this.showToast('点赞失败');
});
},
/**
* 点踩
*/
handleThumbDown(messageId, index) {
if (!messageId) {
this.showToast('消息ID不存在');
return;
}
// 去除AI消息ID中的'ai-'前缀
const actualMessageId = messageId.replace('ai-', '');
sendFeedback({
messageId: actualMessageId,
action: 0,
user: this.user
}).then(res => {
if (res.code === 200) {
this.showToast('已反馈', 'success');
// 更新本地消息的反馈状态
this.$set(this.messages[index], 'feedback', { rating: 'dislike' });
} else {
this.showToast(res.msg || '反馈失败');
}
}).catch(err => {
console.error('反馈失败:', err);
this.showToast('反馈失败');
});
},
/**
* 将引用资源按文档名称分组合并
* @param {Array} references - 原始引用资源数组
* @returns {Array} 分组后的引用资源数组
*/
getGroupedReferences(references) {
if (!references || !references.length) return [];
const grouped = {};
// 按文档名称分组
references.forEach(ref => {
const docName = ref.document_name;
if (!grouped[docName]) {
grouped[docName] = {
document_name: docName,
references: []
};
}
grouped[docName].references.push(ref);
});
// 转换为数组
return Object.values(grouped);
},
/**
* 切换单个引用的显示状态
* @param {number} msgIdx - 消息索引
* @param {number} refIdx - 引用索引
*/
toggleSingleReference(msgIdx, refIdx) {
if (!this.showSingleReference[msgIdx]) {
this.$set(this.showSingleReference, msgIdx, {});
}
const current = this.showSingleReference[msgIdx][refIdx];
this.$set(this.showSingleReference[msgIdx], refIdx, !current);
},
/**
* 显示提示
*/
showToast(title, icon = 'none') {
uni.showToast({ title, icon });
}
}
};
</script>
<style scoped>
/* 整体容器 */
.chat-container {
display: flex;
flex-direction: column;
/* 原来是 height: 100vh; 改为: */
height: calc(100vh - var(--window-top) - var(--window-bottom));
width: 100%;
background-color: #f5f5f5;
overflow: hidden;
}
/* 消息列表 */
.message-list {
flex: 1;
/* height: 300rpx; <- 删除这行 */
min-height: 0;
/* 关键:允许在 flex 布局里被压缩从而出现滚动 */
padding: 20rpx 0;
background-color: #f5f5f5;
box-sizing: border-box;
}
/* 加载提示 */
.loading-history {
text-align: center;
padding: 20px;
color: #666;
font-size: 14px;
}
.no-more-history {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
}
/* 消息项 */
.message-item {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
max-width: 100%;
}
.user-message {
flex-direction: row-reverse;
margin-right: 20px;
}
.ai-message {
flex-direction: row;
margin-left: 20px;
}
/* 头像 */
.avatar {
width: 40px;
height: 40px;
border-radius: 8px;
margin: 0 8px;
}
/* 消息内容 */
.message-content {
max-width: 70%;
padding: 10px 12px;
border-radius: 10px;
word-wrap: break-word;
line-height: 1.5;
}
.user-message .message-content {
background-color: #e1f5fe;
}
.ai-message .message-content {
background-color: #fff;
border: 1px solid #eee;
}
/* AI提示区域 */
.ai-hint {
margin-top: 8px;
font-size: 12px;
color: #666;
}
/* 引用来源部分 */
.reference-section {
margin-top: 8px;
margin-bottom: 8px;
padding: 8px;
background-color: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #007aff;
}
.reference-title {
font-size: 12px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
display: block;
}
/* 每个引用项容器 */
.reference-item-wrapper {
margin-top: 6px;
}
/* 可点击文档名 */
.doc-name-link {
color: #007aff;
text-decoration: underline;
margin-right: 8px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
}
/* 引用详情 */
.reference-details-item {
margin-top: 6px;
padding: 8px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
font-size: 11px;
color: #555;
line-height: 1.5;
}
.reference-meta {
font-weight: bold;
color: #333;
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.reference-content {
color: #444;
line-height: 1.6;
font-size: 10px;
}
/* 引用分段样式 */
.reference-segment {
margin-bottom: 12px;
padding: 8px;
background-color: #fafafa;
border-left: 3px solid #007aff;
border-radius: 4px;
}
.reference-segment:last-child {
margin-bottom: 0;
}
/* 分段分隔线 */
.reference-divider {
height: 2px;
background: linear-gradient(to right, #e8e8e8, #f5f5f5, #e8e8e8);
margin: 12px 0;
width: 100%;
border-radius: 1px;
}
/* AI操作区域 */
.ai-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: #666;
}
.ai-text {
color: #999;
font-size: 11px;
line-height: 1.4;
flex: 1;
margin-right: 8px;
}
.icon-group {
display: flex;
gap: 12px;
}
/* 图标按钮样式 - 适配u-icon组件 */
.btn-icon {
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.6;
margin: 0 4px;
}
.btn-icon-active {
opacity: 1;
transform: scale(1.1);
}
/* 针对u-icon组件的特殊样式 */
.btn-icon.u-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* 激活状态下的颜色变化 */
.btn-icon-active.u-icon {
color: #007aff !important;
}
/* 输入框区域 */
.input-container {
display: flex;
align-items: center;
padding: 8px 16px;
background-color: #fff;
border-top: 1px solid #eee;
box-sizing: border-box;
padding-bottom: calc(env(safe-area-inset-bottom) + 10px);
}
.input-container input {
flex: 1;
height: 40px;
padding: 0 16px;
background-color: #f5f5f5;
border: none;
border-radius: 20px;
font-size: 14px;
margin-right: 10px;
outline: none;
}
.send-button {
padding: 6px 12px;
background-color: #007aff;
color: white;
border: none;
border-radius: 16px;
font-size: 12px;
cursor: pointer;
min-width: 60px;
}
.send-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* Markdown内容样式 */
.markdown-content {
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
margin: 10px 0 5px 0;
font-weight: bold;
}
.markdown-content p {
margin: 5px 0;
}
.markdown-content code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-content pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
margin: 10px 0;
}
.markdown-content ul,
.markdown-content ol {
padding-left: 20px;
margin: 5px 0;
}
.markdown-content li {
margin: 2px 0;
}
</style>