Files
zhxg_app/pages/aiChat/simple_chat.vue

947 lines
23 KiB
Vue
Raw Normal View History

2025-08-14 11:42:34 +08:00
<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>
2025-08-14 15:38:29 +08:00
<!-- 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方式已注释
2025-08-14 15:38:29 +08:00
<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)" />
-->
2025-08-14 15:38:29 +08:00
</view>
2025-08-14 11:42:34 +08:00
</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,
2025-08-14 15:38:29 +08:00
earliestMessageId: null,
// 引用信息展示控制
showSingleReference: {}
};
2025-08-14 11:42:34 +08:00
},
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,
2025-08-14 15:38:29 +08:00
feedback: msg.feedback || null, // 添加反馈状态
retrieverResources: msg.retriever_resources || [] // 添加引用资源
2025-08-14 11:42:34 +08:00
});
}
});
// 按时间排序(从旧到新)
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,
2025-08-14 15:38:29 +08:00
feedback: msg.feedback || null, // 添加反馈状态
retrieverResources: msg.retriever_resources || [] // 添加引用资源
2025-08-14 11:42:34 +08:00
});
}
});
// 按时间排序(从旧到新)
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('反馈失败');
});
},
2025-08-14 15:38:29 +08:00
/**
* 将引用资源按文档名称分组合并
* @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);
},
2025-08-14 11:42:34 +08:00
/**
* 显示提示
*/
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;
}
2025-08-14 15:38:29 +08:00
/* 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;
}
2025-08-14 11:42:34 +08:00
/* AI操作区域 */
.ai-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: #666;
}
.ai-text {
color: #999;
2025-08-14 15:38:29 +08:00
font-size: 11px;
line-height: 1.4;
flex: 1;
margin-right: 8px;
2025-08-14 11:42:34 +08:00
}
.icon-group {
display: flex;
gap: 12px;
}
/* 图标按钮样式 - 适配u-icon组件 */
2025-08-14 11:42:34 +08:00
.btn-icon {
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.6;
margin: 0 4px;
2025-08-14 11:42:34 +08:00
}
.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;
}
2025-08-14 11:42:34 +08:00
/* 输入框区域 */
.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 {
2025-08-14 15:38:29 +08:00
padding: 6px 12px;
2025-08-14 11:42:34 +08:00
background-color: #007aff;
color: white;
border: none;
2025-08-14 15:38:29 +08:00
border-radius: 16px;
font-size: 12px;
2025-08-14 11:42:34 +08:00
cursor: pointer;
2025-08-14 15:38:29 +08:00
min-width: 60px;
2025-08-14 11:42:34 +08:00
}
.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>