AI聊天更新历史记录

This commit is contained in:
14651
2025-08-14 00:36:04 +08:00
parent 3743c72a54
commit 362f286759
7 changed files with 1181 additions and 702 deletions

7
.trae/TODO.md Normal file
View File

@@ -0,0 +1,7 @@
# TODO:
- [x] 1: 分析控制台输出确定API返回数据的正确结构 (priority: High)
- [x] 2: 修复initChat方法中AI消息内容的数据映射 (priority: High)
- [x] 3: 修复loadMoreHistory方法中的数据映射 (priority: High)
- [x] 4: 移除调试代码恢复正常UI显示 (priority: Medium)
- [x] 5: 测试修复后的AI消息显示功能 (priority: Medium)

View File

@@ -1,38 +1,78 @@
// src/api/index.js // src/api/ai_index.js
// import request from '@/utils/ai_request.js' // import request from '@/utils/ai_request.js'
import request from "../../utils/ai_request"; import request from "@/utils/ai_request.js";
// 获取历史 // 获取历史
export const getHistory = ({ export const getHistory = ({
conversationId, conversationId,
user, user,
limit = 20 limit = 20,
beforeId
}) => { }) => {
return request({ const params = {
url: '/aitutor/aichat/getMessagesToUser',
method: 'get',
params: {
conversationId, conversationId,
user, user,
limit limit
};
// 如果有beforeId参数添加到请求中
if (beforeId) {
params.beforeId = beforeId;
} }
// headers: {
// Authorization: 'Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjBmMTY3NmY2LTgwOGMtNGUwMC04NDJjLWIwNmY1ZTM5NzJlNCJ9.VVc6OwQ-Xn9pxzYbPhlCpvDp6TwESS00gJi9IXUEIbFw4RFACZDmYCYjQ7voTM4fppy9SAMJCWT-L7Uy-K1eqw' return request({
// } url: '/aitutor/aichat/getMessagesToUser',
method: 'get',
params
}); });
}; };
// export const getHistory = ({
// conversationId,
// user,
// limit = 20
// }) => {
// return request({
// url: '/aitutor/aichat/getMessagesToUser',
// method: 'get',
// params: {
// conversationId,
// user,
// limit
// }
// // headers: {
// // Authorization: 'Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjBmMTY3NmY2LTgwOGMtNGUwMC04NDJjLWIwNmY1ZTM5NzJlNCJ9.VVc6OwQ-Xn9pxzYbPhlCpvDp6TwESS00gJi9IXUEIbFw4RFACZDmYCYjQ7voTM4fppy9SAMJCWT-L7Uy-K1eqw'
// // }
// });
// };
// 点赞/点踩 action: 1 点赞 0 点踩 // 点赞/点踩 action: 1 点赞 0 点踩
export const sendFeedback = ({ export const sendFeedback = ({
messageId, messageId,
action action,
user
}) => { }) => {
return request({ return request({
url: '/api/chat/feedback', url: '/aitutor/aichat/feedback',
method: 'post', method: 'post',
data: { data: {
messageId, message_id: messageId,
action rating: action === 1 ? 'like' : 'dislike', // 添加rating参数
user
} }
}); });
}; };
// export const sendFeedback = ({
// messageId,
// action
// }) => {
// return request({
// url: '/api/chat/feedback',
// method: 'post',
// data: {
// messageId,
// action
// }
// });
// };

View File

@@ -52,106 +52,94 @@
<script> <script>
import { import {
getHistory getHistory
} from '../../api/aiChat/ai_index.js'; // 历史记录API } from '../../api/aiChat/ai_index.js';
export default { export default {
name: 'HistoryDrawer', name: 'HistoryDrawer',
props: { props: {
visible: Boolean // 控制抽屉显示 visible: Boolean
}, },
data() { data() {
return { return {
historyRecords: [], // 原始历史记录 historyRecords: [],
filteredRecords: [], // 过滤后的历史记录 filteredRecords: [],
searchKeyword: '' // 搜索关键词 searchKeyword: '', // 移除了重复定义
loading: false // 新增loading状态
}; };
}, },
watch: { watch: {
// 监听visible变化显示时加载记录
visible(newVal) { visible(newVal) {
if (newVal) this.loadHistoryRecords(); if (newVal) this.loadHistoryRecords();
else this.clearSearch(); else this.clearSearch();
} }
}, },
methods: { methods: {
// 关闭抽屉
closeDrawer() { closeDrawer() {
this.$emit('close'); this.$emit('close');
}, },
/* ========== 1. 读本地缓存兜底 ========== */
renderLocal(list) {
// 将本地缓存的消息按日期分组
const groupMap = {};
list.forEach((m, idx) => {
const date = new Date();
const formatted = {
date: this.formatDate(date),
time: this.formatTime(date),
content: m.sender === 'user' ? m.content : '',
reply: m.sender === 'ai' ? m.content : '',
id: idx
};
const title = this.getGroupTitle(date);
(groupMap[title] ||= []).push(formatted);
});
// 转换为数组形式
this.historyRecords = Object.entries(groupMap).map(([t, arr]) => ({
title: t,
list: arr
}));
this.filteredRecords = [...this.historyRecords];
},
/* ========== 2. 加载接口或本地缓存 ========== */
async loadHistoryRecords() { async loadHistoryRecords() {
this.loading = true;
try { try {
// 1. 先读本地缓存,立即显示(避免空白) // 1. 获取当前用户学号
const local = uni.getStorageSync('chatHistory') || []; const userNo = uni.getStorageSync('stuNo');
if (local.length) this.renderLocal(local); if (!userNo) throw new Error('未获取到用户学号');
// 2. 再调接口,成功后覆盖本地 // 2. 调用接口获取数据
const res = await getHistory({ const res = await getHistory({
conversationId: '5665af64-22b4-4a59-b15f-2126eb056302', user: userNo,
user: '2023429112', conversationId: '',
limit: 50 limit: 20
}); });
console.log('原始返回结构', res); console.log('接口响应:', res);
const list = res?.data?.data || []; // 3. 处理响应数据 - 修正数据结构解析
if (!list.length) return; // 接口返回空数组也保留本地 const list = Array.isArray(res.data?.data) ? res.data.data : [];
console.log('解析后的数据列表:', list);
// 处理API返回的数据 // 4. 分组处理
const groupMap = {}; const groupMap = {};
list.forEach(item => { list.forEach(item => {
const ts = item.created_at ?? item.create_time ?? item.timestamp ?? 0; // 修正字段映射
const date = String(ts).length === 13 ? new Date(ts) : new Date(ts * 1000); const timestamp = item.created_at || item.create_time || item.timestamp || Date.now() /
1000;
const date = new Date(timestamp * 1000);
const formatted = { const record = {
id: item.id || Math.random().toString(36).slice(2),
date: this.formatDate(date), date: this.formatDate(date),
time: this.formatTime(date), time: this.formatTime(date),
content: item.query || item.question || item.content || '未知内容', content: item.query || item.content || '未知内容',
reply: item.answer || item.reply || '暂无回复', reply: item.answer || item.reply || '暂无回复',
id: item.id || Math.random().toString(36).slice(2) timestamp: timestamp
}; };
const title = this.getGroupTitle(date); const title = this.getGroupTitle(date);
(groupMap[title] ||= []).push(formatted); (groupMap[title] ||= []).push(record);
}); });
// 按时间排序并更新数据 // 5. 排序并更新数据
this.historyRecords = Object.entries(groupMap).map(([title, arr]) => ({ this.historyRecords = Object.entries(groupMap).map(([title, arr]) => ({
title, title,
list: arr.sort((a, b) => b.timestamp - a.timestamp) list: arr.sort((a, b) => b.timestamp - a.timestamp)
})); }));
this.filteredRecords = [...this.historyRecords]; this.filteredRecords = [...this.historyRecords];
console.log('最终处理的历史记录:', this.historyRecords);
} catch (e) { } catch (e) {
console.error('历史接口 401已使用本地缓存兜底', e); console.error('加载失败:', e);
// 接口失败时,保留本地缓存(不覆盖) uni.showToast({
title: `加载失败: ${e.message}`,
icon: 'none',
duration: 3000
});
this.historyRecords = [];
} finally {
this.loading = false;
} }
}, },
// 处理搜索输入 // 处理搜索输入
handleSearch() { handleSearch() {
const kw = this.searchKeyword.trim().toLowerCase(); const kw = this.searchKeyword.trim().toLowerCase();

View File

@@ -1,8 +1,5 @@
<!-- pages/aiChat/ai_index -->
<template> <template>
<view class="chat-container"> <view class="chat-container">
<!-- 状态保持当AI聊天可见时才显示聊天内容 -->
<!-- 状态栏占位 --> <!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view> <view class="status-bar-placeholder"></view>
@@ -14,16 +11,23 @@
</view> </view>
<!-- 中间标题 --> <!-- 中间标题 -->
<view class="nav-title">智水AI辅导员</view> <view class="nav-title">智水AI辅导员</view>
<!-- 右侧新建聊天图标 -->
<view class="nav-right" @click="newChat">
<image src="/static/newChat.svg" mode="aspectFit" class="nav-icon"></image>
</view>
</view> </view>
<!-- 消息列表 --> <!-- 消息列表 -->
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation> <scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation enable-passive="true"
<block v-for="(item, index) in messages" :key="index"> @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']"> <view :class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
<!-- 用户/AI头像 --> <!-- 用户/AI头像 -->
<image class="avatar" :src="item.avatar"></image> <image class="avatar" :src="item.avatar"></image>
@@ -34,12 +38,12 @@
<text v-else-if="item.content">{{ item.content }}</text> <text v-else-if="item.content">{{ item.content }}</text>
<!-- 图片内容 --> <!-- 图片内容 -->
<image v-if="item.image" :src="item.image" class="sent-image"></image> <image v-if="item.image" :src="item.image" class="sent-image"></image>
<!-- AI 特有内容 --> <!-- AI 特有内容 -->
<view v-if="item.sender === 'ai'" class="ai-hint"> <view v-if="item.sender === 'ai'" class="ai-hint">
<!-- 引用来源部分 --> <!-- 引用来源部分 -->
<view v-if="item.retrieverResources && item.retrieverResources.length" <view v-if="item.retrieverResources && item.retrieverResources.length"
class="reference-section"> class="reference-section">
<view class="reference-section">
<text class="reference-title">引用来源</text> <text class="reference-title">引用来源</text>
<!-- 遍历每个引用资源 --> <!-- 遍历每个引用资源 -->
<view v-for="(ref, idx) in item.retrieverResources" :key="idx" <view v-for="(ref, idx) in item.retrieverResources" :key="idx"
@@ -57,7 +61,7 @@
</view> </view>
</view> </view>
</view> </view>
</view>
<!-- AI操作区域点赞/点踩 --> <!-- AI操作区域点赞/点踩 -->
<view class="ai-actions"> <view class="ai-actions">
<text class="ai-text">回答由AI生成</text> <text class="ai-text">回答由AI生成</text>
@@ -98,60 +102,58 @@
<script> <script>
/* ========== 依赖 ========== */ /* ========== 依赖 ========== */
import HistoryDrawer from '@/components/aiChat/HistoryDrawer.vue'; // 历史记录抽屉组件 import HistoryDrawer from '@/components/aiChat/HistoryDrawer.vue';
import { import {
createChatStream createChatStream
} from '@/utils/ai_stream.js'; // 流式聊天API } from '@/utils/ai_stream.js';
import MarkdownIt from 'markdown-it'; // Markdown解析器 import {
import DOMPurify from 'dompurify'; // HTML净化器 sendFeedback,
getHistory
} from '@/api/aiChat/ai_index.js';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
/* DOMPurify 白名单加固 */ /* DOMPurify 白名单加固 */
DOMPurify.addHook('afterSanitizeAttributes', node => { DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer'); if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
}); });
// 初始化Markdown解析器
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
linkify: true, linkify: true,
typographer: true typographer: true
}); });
/* ========== 配置 ========== */
const BASE_URL = (function() {
// #ifdef H5
return 'http://localhost:8080/dev-api/aitutor/aichat'; // H5环境API地址
// #endif
// #ifndef H5
// return 'http://192.168.31.123:8080/aitutor/aichat'; // 真机调试改成你的电脑 IP
// #endif
})();
/* ========== 组件 ========== */
export default { export default {
components: { components: {
HistoryDrawer HistoryDrawer
}, },
data() { data() {
return { return {
showHistoryDrawer: false, // 是否显示历史记录抽屉 showHistoryDrawer: false,
inputMessage: '', // 用户输入的消息 inputMessage: '',
messages: [], // 聊天消息列表 messages: [],
scrollTop: 0, // 滚动位置 scrollTop: 0,
conversation_id: null, // 当前会话ID conversation_id: null,
showSingleReference: {}, // 控制引用来源的显示状态 showSingleReference: {},
currentReader: null, // 当前流式读取器 currentCancel: null,
/* 新增锁 & 基础信息 */ sending: false,
sending: false, // 是否正在发送消息 user: uni.getStorageSync('stuNo') || '',
user: uni.getStorageSync('stuNo') || '', // 用户学号 userId: uni.getStorageSync('stuId') || '',
userId: uni.getStorageSync('stuId') || '', // 用户ID userName: uni.getStorageSync('stuName') || '',
userName: uni.getStorageSync('stuName') || '' // 用户名 // 分页加载相关字段
isLoadingHistory: false,
hasMoreHistory: true,
earliestMessageId: null,
scrollHeight: 0,
lastScrollTime: 0,
scrollDebounce: 100,
lastUserScrollTime: 0, // 添加这个字段用于智能滚动
}; };
}, },
/* ---------- 生命周期 ---------- */ /* ---------- 生命周期 ---------- */
onLoad() { onLoad() {
// 页面加载时检查登录状态
if (!this.user) { if (!this.user) {
this.$toast('请先登录'); this.$toast('请先登录');
setTimeout(() => uni.navigateTo({ setTimeout(() => uni.navigateTo({
@@ -159,40 +161,112 @@
}), 1500); }), 1500);
return; return;
} }
this.initChat(); // 初始化聊天
// 从本地存储获取conversation_id
this.conversation_id = uni.getStorageSync('conversation_id') || null;
// 初始化聊天并确保滚动到底部
this.initChat().then(() => {
// 多重延迟确保DOM完全渲染
this.$nextTick(() => {
setTimeout(() => {
this.forceScrollToBottom();
// 再次确保滚动(处理某些设备的渲染延迟)
setTimeout(() => {
this.forceScrollToBottom();
}, 500);
}, 300);
});
});
}, },
onUnload() {
// 页面卸载时取消流式读取 /**
this.currentReader?.cancel?.('页面卸载'); * 页面显示时触发 - 确保每次进入页面都滚动到底部
this.currentReader = null; */
}, onShow() {
async onPullDownRefresh() { // 确保页面显示时滚动到底部
// 下拉刷新 this.$nextTick(() => {
await this.initChat(); setTimeout(() => {
uni.stopPullDownRefresh(); this.forceScrollToBottom();
this.$toast('刷新成功', 'success'); // 额外延迟处理某些设备的渲染问题
setTimeout(() => {
this.forceScrollToBottom();
}, 200);
}, 100);
});
}, },
/* ---------- 方法 ---------- */ /* ---------- 方法 ---------- */
methods: { methods: {
// 返回首页 /**
* 返回首页
*/
goHome() { goHome() {
uni.reLaunch({ uni.reLaunch({
url: '/pages/index/index' url: '/pages/index/index'
}); });
}, },
// 切换历史记录抽屉显示状态 /**
* 强制滚动到底部 - 优化版本
*/
forceScrollToBottom() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list').boundingClientRect(rect => {
if (rect && rect.scrollHeight > rect.height) {
// 计算需要滚动的距离
const targetScrollTop = rect.scrollHeight - rect.height + 50; // 额外50px确保完全显示
this.scrollTop = targetScrollTop;
// 双重确保滚动生效
setTimeout(() => {
this.scrollTop = targetScrollTop + 1;
setTimeout(() => {
this.scrollTop = targetScrollTop;
}, 50);
}, 100);
}
}).exec();
});
},
forceScrollToTop() {
this.$nextTick(() => {
// 直接滚动到顶部
this.scrollTop = 999;
// 双重确保滚动生效
setTimeout(() => {
this.scrollTop = 1;
setTimeout(() => {
this.scrollTop = 0;
}, 50);
}, 100);
});
},
/**
* 切换历史记录抽屉显示状态
*/
toggleHistoryDrawer() { toggleHistoryDrawer() {
this.showHistoryDrawer = !this.showHistoryDrawer; this.showHistoryDrawer = !this.showHistoryDrawer;
}, },
// 渲染Markdown内容 /**
* 渲染Markdown内容
* @param {string} text - 需要渲染的Markdown文本
* @returns {string} 安全的HTML内容
*/
renderMarkdown(text) { renderMarkdown(text) {
return DOMPurify.sanitize(md.render(text || '')); return DOMPurify.sanitize(md.render(text || ''));
}, },
/* 轻量 toast 封装 */ /**
* 显示提示消息
* @param {string} title - 提示内容
* @param {string} icon - 图标类型
*/
$toast(title, icon = 'none') { $toast(title, icon = 'none') {
uni.showToast({ uni.showToast({
title, title,
@@ -200,61 +274,326 @@
}); });
}, },
/* 获取历史会话 or 欢迎语 */ /**
async initChat() { * 历史记录点击处理
if (!this.user) return this.initConversation(); * @param {Object} item - 历史记录项
try { */
// 请求获取用户历史消息 onHistoryItemClick(item) {
const res = await uni.request({ // 清理当前消息和分页状态
url: BASE_URL + '/getMessagesToUser', this.messages = [];
method: 'POST', this.isLoadingHistory = false;
data: { this.hasMoreHistory = true;
user: this.user this.earliestMessageId = null;
}
}); // 添加历史对话到消息列表
const data = res.data || {}; const userMessage = {
if (data.code === 200 && data.data?.conversationId) { sender: 'user',
// 设置会话ID和消息列表 avatar: '/static/yonghu.png',
this.conversation_id = data.data.conversationId; 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); uni.setStorageSync('conversation_id', this.conversation_id);
this.messages = Array.isArray(data.data.messages) && data.data.messages.length ?
data.data.messages :
this.welcomeMessage();
} else {
this.initConversation();
}
} catch (e) {
console.error('获取历史失败', e);
this.$toast('连接失败,使用新会话');
this.initConversation();
} }
// 确保滚动到底部
this.$nextTick(() => {
setTimeout(() => {
this.scrollToBottom();
}, 300);
});
this.saveMessagesToLocal(); // 保存到本地
this.$toast('已加载历史对话', 'success');
}, },
/* 欢迎语兜底 */ /**
welcomeMessage() { * 初始化聊天 - 从接口获取历史记录
*/
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 [{ return [{
sender: 'ai', sender: 'ai',
avatar: '/static/AI.png', avatar: '/static/AI.png',
content: '你好!我是您的 AI 小助手,有什么可以帮您?😊', content: '你好!我是您的 AI 小助手,有什么可以帮您?😊',
retrieverResources: [], retrieverResources: [],
image: '' image: '',
messageId: 'welcome-' + Date.now().toString()
}]; }];
}, },
// 初始化新会话 /**
initConversation() { * 基于SSE的发送消息实现
uni.removeStorageSync('chatHistory'); */
this.conversation_id = null;
this.messages = this.welcomeMessage();
},
/* 发送消息(带并发锁) */
async sendMessage() { async sendMessage() {
const msg = this.inputMessage.trim(); const msg = this.inputMessage.trim();
if (!msg || this.sending) return; if (!msg || this.sending) return;
this.sending = true; this.sending = true;
// 添加用户消息到列表 // 添加用户消息
this.messages.push({ this.messages.push({
sender: 'user', sender: 'user',
avatar: '/static/yonghu.png', avatar: '/static/yonghu.png',
@@ -263,23 +602,29 @@
messageId: Date.now().toString() messageId: Date.now().toString()
}); });
this.inputMessage = ''; this.inputMessage = '';
this.saveMessagesToLocal(); // 保存用户消息
// 添加AI占位消息 // 添加AI消息占位
const aiIdx = this.messages.push({ const aiIdx = this.messages.push({
sender: 'ai', sender: 'ai',
avatar: '/static/AI.png', avatar: '/static/AI.png',
content: '', content: '<span class="loading-text">正在思考...</span>',
retrieverResources: [], retrieverResources: [],
image: '', image: '',
messageId: null messageId: 'pending-' + Date.now().toString()
}) - 1; }) - 1;
this.$set(this.showSingleReference, aiIdx, {}); this.$set(this.showSingleReference, aiIdx, {});
this.scrollToBottom(); this.scrollToBottom();
// 取消之前的请求
if (this.currentCancel) {
this.currentCancel('新消息发送,终止旧连接');
}
try { try {
// 创建流式聊天连接
const { const {
stream stream,
cancel
} = createChatStream({ } = createChatStream({
conversationId: this.conversation_id, conversationId: this.conversation_id,
prompt: msg, prompt: msg,
@@ -287,13 +632,15 @@
userId: this.userId, userId: this.userId,
userName: this.userName userName: this.userName
}); });
this.currentCancel = cancel;
const { const {
reader, reader,
decoder decoder
} = await stream; } = await stream;
let buffer = ''; let buffer = '';
// 流式读取数据 // 流式处理响应
while (true) { while (true) {
const { const {
done, done,
@@ -306,113 +653,183 @@
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; buffer = lines.pop() || '';
// 处理每行数据 for (const line of lines) {
for (const l of lines) { if (!line.trim()) continue;
if (!l.startsWith('data:')) continue;
const text = l.slice(5).trim();
if (text === '[DONE]') continue;
try { try {
const json = JSON.parse(text); let jsonStr = line.replace(/^data:/, '').trim();
// 处理消息内容 const data = JSON.parse(jsonStr);
if (json.event === 'message' && json.answer) {
this.$set(this.messages[aiIdx], 'content', this.messages[aiIdx].content + json // 更新消息内容
.answer); if (data.event === 'message' && data.answer) {
this.scrollToBottom(); 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 (json.event === 'message_end') { // 处理结束消息
if (json['conversation id']) this.conversation_id = json['conversation id']; if (data.event === 'message_end') {
if (json.metadata?.retriever_resources) { if (data.conversation_id) {
this.$set(this.messages[aiIdx], 'retrieverResources', json.metadata 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); .retriever_resources);
this.$set(this.messages[aiIdx], 'messageId', json.message_id || Date.now()
.toString());
} }
if (data.message_id) {
this.$set(this.messages[aiIdx], 'messageId', data.message_id);
}
// 最终完成后再次确保滚动到底部
this.forceScrollToTop();
} }
} catch (e) { } catch (e) {
console.warn('JSON 解析失败', text, e); console.warn('JSON解析失败:', line, e);
} }
} }
} }
} catch (e) { } catch (e) {
console.error('流式请求失败', e); console.error('流式请求失败:', e);
this.$set(this.messages[aiIdx], 'content', '抱歉,AI 回复失败,请重试。'); this.$set(this.messages[aiIdx], 'content', 'AI回复失败: ' + (e.message || '网络错误'));
} finally { } finally {
this.sending = false; this.sending = false;
this.scrollToBottom(); this.currentCancel = null;
uni.setStorageSync('chatHistory', this.messages);
// 确保AI消息有有效的messageId
if (this.messages[aiIdx].messageId.startsWith('pending-')) {
this.$set(this.messages[aiIdx], 'messageId', 'ai-' + Date.now().toString());
}
// 保存消息到本地存储
this.saveMessagesToLocal();
} }
}, },
/* 滚动到底部 */ /**
scrollToBottom() { * 滚动到底部
* @param {boolean} isStreaming - 是否是流式响应中的滚动
*/
// 修改原有scrollToBottom方法
scrollToBottom(isStreaming = false) {
const now = Date.now();
if (now - this.lastScrollTime < this.scrollDebounce && isStreaming) {
return;
}
this.lastScrollTime = now;
this.$nextTick(() => { this.$nextTick(() => {
uni.createSelectorQuery() setTimeout(() => {
.in(this) const query = uni.createSelectorQuery().in(this);
.select('.message-list') query.select('.message-list').boundingClientRect(rect => {
.boundingClientRect(rect => { if (rect) {
this.scrollTop = (rect?.height || 0) + 9999; // 确保滚动到最底部
}) this.scrollTop = rect.scrollHeight;
.exec(); }
}).exec();
}, isStreaming ? 50 : 100);
}); });
}, },
// 切换单个引用来源的显示状态 /**
* 切换单个引用的显示状态
* @param {number} msgIdx - 消息索引
* @param {number} refIdx - 引用索引
*/
toggleSingleReference(msgIdx, refIdx) { toggleSingleReference(msgIdx, refIdx) {
if (!this.showSingleReference[msgIdx]) this.$set(this.showSingleReference, msgIdx, {}); if (!this.showSingleReference[msgIdx]) this.$set(this.showSingleReference, msgIdx, {});
const cur = this.showSingleReference[msgIdx][refIdx]; const cur = this.showSingleReference[msgIdx][refIdx];
this.$set(this.showSingleReference[msgIdx], refIdx, !cur); this.$set(this.showSingleReference[msgIdx], refIdx, !cur);
}, },
// 点赞消息处理 /**
* 点赞处理
* @param {string} id - 消息ID
*/
handleThumbUpClick(id) { handleThumbUpClick(id) {
if (!id) return; if (!id) {
uni.request({ this.$toast('消息ID不存在', 'error');
url: BASE_URL + '/feedback', return;
method: 'POST',
data: {
messageId: id,
action: 1
} }
}).then(() => this.$toast('点赞成功', 'success'))
.catch(() => this.$toast('点赞失败')); 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) { handleThumbDownClick(id) {
if (!id) return; if (!id) {
uni.request({ this.$toast('消息ID不存在', 'error');
url: BASE_URL + '/feedback', return;
method: 'POST',
data: {
messageId: id,
action: 0
} }
}).then(() => this.$toast('已反馈'))
.catch(() => this.$toast('反馈失败')); 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');
});
}, },
// 新建聊天会话 /**
newChat() { * 滚动事件监听
this.messages = []; * @param {Object} e - 事件对象
this.conversation_id = null; */
uni.removeStorageSync('conversation_id'); onScroll(e) {
uni.removeStorageSync('chatHistory'); this.scrollHeight = e.detail.scrollHeight;
this.initConversation(); this.lastUserScrollTime = Date.now();
this.$toast('新聊天已开始');
}, },
/* 选择图片并上传 */ /**
* 智能滚动到顶部
* 如果用户在3秒内没有滚动则自动滚动到顶部
*/
smartScrollToTop() {
// 如果用户在3秒内没有滚动则自动滚动到顶部
if (Date.now() - this.lastUserScrollTime > 3000) {
this.forceScrollToTop();
}
},
/**
* 选择图片并上传
*/
selectImage() { selectImage() {
uni.chooseImage({ uni.chooseImage({
count: 1, count: 1,
sizeType: ['compressed'], // 压缩 sizeType: ['compressed'],
sourceType: ['album', 'camera'], sourceType: ['album', 'camera'],
success: res => { success: res => {
const temp = res.tempFilePaths[0]; const temp = res.tempFilePaths[0];
// 上传图片
uni.uploadFile({ uni.uploadFile({
url: BASE_URL + '/files/upload', url: '/aitutor/aichat/files/upload',
filePath: temp, filePath: temp,
name: 'file', name: 'file',
formData: { formData: {
@@ -428,7 +845,6 @@
url url
} = JSON.parse(data); } = JSON.parse(data);
if (url) { if (url) {
// 添加图片消息到列表
this.messages.push({ this.messages.push({
sender: 'user', sender: 'user',
avatar: '/static/yonghu.png', avatar: '/static/yonghu.png',
@@ -437,6 +853,7 @@
messageId: Date.now().toString() messageId: Date.now().toString()
}); });
this.scrollToBottom(); this.scrollToBottom();
this.saveMessagesToLocal(); // 保存图片消息
} }
} catch (e) { } catch (e) {
this.$toast('上传解析失败'); this.$toast('上传解析失败');
@@ -444,7 +861,8 @@
}, },
fail: () => this.$toast('上传失败') fail: () => this.$toast('上传失败')
}); });
} },
fail: () => this.$toast('选择图片失败')
}); });
} }
} }

View File

@@ -8,7 +8,7 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background-color: #F5F5F5; background-color: #f5f5f5;
padding-top: 10px; padding-top: 10px;
/* 为固定导航栏预留空间 */ /* 为固定导航栏预留空间 */
box-sizing: border-box; box-sizing: border-box;
@@ -45,6 +45,7 @@
color: #333; color: #333;
text-align: center; text-align: center;
flex: 1; flex: 1;
margin-right: 45px;
} }
.nav-icon { .nav-icon {
@@ -166,7 +167,7 @@
background-color: #fff; background-color: #fff;
border-top: 1px solid #eee; border-top: 1px solid #eee;
position: fixed; position: fixed;
bottom: 0; bottom: -1px;
left: 0; left: 0;
width: 100%; width: 100%;
z-index: 10; z-index: 10;
@@ -260,10 +261,9 @@
padding-left: 1em; padding-left: 1em;
} }
/* 可点击文档名 */ /* 可点击文档名 */
.doc-name-link { .doc-name-link {
color: #007AFF; color: #007aff;
text-decoration: underline; text-decoration: underline;
margin-right: 16rpx; margin-right: 16rpx;
font-size: 10rpx; font-size: 10rpx;
@@ -301,7 +301,7 @@
} }
.doc-name-link { .doc-name-link {
color: #007AFF; color: #007aff;
text-decoration: underline; text-decoration: underline;
margin-right: 16rpx; margin-right: 16rpx;
font-size: clamp(13px, 3vw, 15px); font-size: clamp(13px, 3vw, 15px);
@@ -344,6 +344,28 @@
transform: scale(0.95); transform: scale(0.95);
} }
.loading-history {
text-align: center;
padding: 20rpx;
color: #999;
font-size: 28rpx;
}
.no-more-history {
text-align: center;
padding: 20rpx;
color: #ccc;
font-size: 24rpx;
}
.debug-info {
color: #888;
font-size: 12px;
margin-top: 5px;
border-top: 1px dashed #eee;
padding-top: 5px;
}
/* ============= 小屏设备适配 ============= */ /* ============= 小屏设备适配 ============= */
@media (max-width: 600px) { @media (max-width: 600px) {
.message-content { .message-content {

View File

@@ -6,9 +6,13 @@ import {
const service = axios.create({ const service = axios.create({
// baseURL: 'http://localhost:9090/dev-api/aitutor/aichat', // baseURL: 'http://localhost:9090/dev-api/aitutor/aichat',
baseURL: 'http://localhost:8088/aitutor/aichat', // baseURL: 'http://localhost:8088/aitutor/aichat',
// baseURL: 'http://localhost:8080/aitutor/aichat', // baseURL: 'http://localhost:8080/aitutor/aichat',
baseURL: 'http://localhost:8088',
timeout: 15000, timeout: 15000,
headers: {
'Content-Type': 'application/json'
}
}) })
// 请求拦截器:统一加 token // 请求拦截器:统一加 token

View File

@@ -1,4 +1,4 @@
// src/utils/ai_stream.js (H5 优化版) // src/utils/ai_stream.js
import { import {
getToken getToken
} from '@/utils/auth'; } from '@/utils/auth';
@@ -23,7 +23,6 @@ export function createChatStream(params) {
const fetchPromise = fetch(url, { const fetchPromise = fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'text/event-stream',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'X-Request-ID': requestId 'X-Request-ID': requestId
@@ -38,20 +37,21 @@ export function createChatStream(params) {
}), }),
signal: controller.signal signal: controller.signal
}) })
.then(resp => { .then(response => {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!resp.body) throw new Error('Response body is null'); if (!response.body) throw new Error('Response body is null');
return resp.body; return {
reader: response.body.getReader(),
decoder: new TextDecoder('utf-8')
};
}); });
return fetchPromise.then(body => ({ return {
stream: Promise.resolve({ stream: fetchPromise,
reader: body.getReader(), cancel: (reason) => {
decoder: new TextDecoder('utf-8') if (!controller.signal.aborted) {
}), controller.abort(reason);
cancel: reason => !controller.signal.aborted && controller.abort(reason) }
})).catch(err => ({ }
stream: Promise.reject(err), };
cancel: () => {}
}));
} }