AI聊天更新历史记录
This commit is contained in:
7
.trae/TODO.md
Normal file
7
.trae/TODO.md
Normal 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)
|
||||||
@@ -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
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// };
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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('选择图片失败')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: () => {}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user