1.完成ai聊天页面新
This commit is contained in:
@@ -15,9 +15,9 @@ export const getHistory = ({
|
|||||||
limit
|
limit
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果有beforeId参数,添加到请求中
|
// 如果有beforeId参数,添加到请求中(后端参数名为firstId)
|
||||||
if (beforeId) {
|
if (beforeId) {
|
||||||
params.beforeId = beforeId;
|
params.firstId = beforeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ module.exports = {
|
|||||||
/**
|
/**
|
||||||
* 开启cas
|
* 开启cas
|
||||||
*/
|
*/
|
||||||
casEnable: true,
|
casEnable: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单点登录url
|
* 单点登录url
|
||||||
|
|||||||
11
pages.json
11
pages.json
@@ -1159,7 +1159,16 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/aiChat/ai_index",
|
"path": "pages/aiChat/ai_index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "AI辅导员"
|
"navigationBarTitleText": "智水AI辅导员"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/aiChat/simple_chat",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "智水AI助手",
|
||||||
|
"enablePullDownRefresh": false,
|
||||||
|
"navigationBarBackgroundColor": "#1890FF",
|
||||||
|
"navigationBarTextStyle": "white"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="chat-container">
|
<view class="chat-container">
|
||||||
<!-- 状态栏占位 -->
|
|
||||||
<view class="status-bar-placeholder"></view>
|
|
||||||
|
|
||||||
<!-- 自定义导航栏 -->
|
<!-- 自定义导航栏 -->
|
||||||
|
<!-- <view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
<view class="custom-nav-bar">
|
<view class="custom-nav-bar">
|
||||||
<!-- 左侧:历史记录图标(触发抽屉) -->
|
|
||||||
<view class="nav-left" @click="toggleHistoryDrawer">
|
<view class="nav-left" @click="toggleHistoryDrawer">
|
||||||
<image src="/static/history.svg" mode="aspectFit" class="nav-icon"></image>
|
<image src="/static/history.svg" mode="aspectFit" class="nav-icon"></image>
|
||||||
</view>
|
</view>
|
||||||
<!-- 中间标题 -->
|
|
||||||
<view class="nav-title">智水AI辅导员</view>
|
<view class="nav-title">智水AI辅导员</view>
|
||||||
</view>
|
</view> -->
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation enable-passive="true"
|
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation="true"
|
||||||
|
show-scrollbar="false" enhanced="true" bounces="true"
|
||||||
@scroll="onScroll" @scrolltoupper="loadMoreHistory" upper-threshold="50">
|
@scroll="onScroll" @scrolltoupper="loadMoreHistory" upper-threshold="50">
|
||||||
<!-- 加载提示 -->
|
<!-- 加载提示 -->
|
||||||
<view v-if="isLoadingHistory" class="loading-history">
|
<view v-if="isLoadingHistory" class="loading-history">
|
||||||
@@ -86,17 +84,17 @@
|
|||||||
<!-- 消息输入框 -->
|
<!-- 消息输入框 -->
|
||||||
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send" />
|
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send" />
|
||||||
<!-- 添加图片按钮 -->
|
<!-- 添加图片按钮 -->
|
||||||
<img src="/static/add.svg" class="add-icon" @click="selectImage" />
|
<!-- <img src="/static/add.svg" class="add-icon" @click="selectImage" /> -->
|
||||||
<!-- 发送消息按钮 -->
|
<!-- 发送消息按钮 -->
|
||||||
<img src="/static/send.svg" class="send-icon" @click="sendMessage" />
|
<button class="send-button" @click="sendMessage">发送</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 悬浮按钮:固定在右下角 -->
|
<!-- 悬浮按钮:固定在右下角 -->
|
||||||
<view class="ai-hover" @click="goHome">
|
<!-- <view class="ai-hover" @click="goHome">
|
||||||
<view class="ai-hover-content">
|
<view class="ai-hover-content">
|
||||||
<text class="ai-hover-text">AI</text>
|
<text class="ai-hover-text">AI</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view> -->
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -174,7 +172,7 @@
|
|||||||
|
|
||||||
// 第二次尝试(处理iOS等需要额外触发的设备)
|
// 第二次尝试(处理iOS等需要额外触发的设备)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.forceScrollToBottom(30); // 使用稍小的偏移量
|
this.forceScrollToBottom(); // 确保滚动到底部
|
||||||
|
|
||||||
// 第三次确保(针对动态内容加载的情况)
|
// 第三次确保(针对动态内容加载的情况)
|
||||||
if (attempt < 2) {
|
if (attempt < 2) {
|
||||||
@@ -210,6 +208,19 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面显示时触发
|
||||||
|
*/
|
||||||
|
onShow() {
|
||||||
|
// 页面显示时确保滚动到底部
|
||||||
|
this.$nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('onShow: 尝试滚动到底部');
|
||||||
|
this.forceScrollToBottom();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/* ---------- 方法 ---------- */
|
/* ---------- 方法 ---------- */
|
||||||
methods: {
|
methods: {
|
||||||
/**
|
/**
|
||||||
@@ -223,28 +234,53 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制滚动到底部
|
* 强制滚动到底部
|
||||||
* @param {number} offset - 额外的偏移量,默认为50
|
* @param {number} offset - 额外的偏移量,默认为0
|
||||||
*/
|
*/
|
||||||
forceScrollToBottom(offset = 50) {
|
forceScrollToBottom(offset = 0) {
|
||||||
|
console.log('forceScrollToBottom: 开始执行滚动');
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
try {
|
try {
|
||||||
const query = uni.createSelectorQuery().in(this);
|
const query = uni.createSelectorQuery().in(this);
|
||||||
query.select('.message-list').boundingClientRect(rect => {
|
query.select('.message-list').scrollOffset(res => {
|
||||||
if (rect) {
|
if (res) {
|
||||||
const targetScrollTop = rect.scrollHeight - rect.height - offset;
|
console.log('forceScrollToBottom: 获取到scrollOffset', res);
|
||||||
this.scrollTop = targetScrollTop;
|
console.log('forceScrollToBottom: 当前scrollTop', this.scrollTop);
|
||||||
|
|
||||||
// 双重确保滚动生效
|
// 使用scrollHeight,如果没有则使用一个大值
|
||||||
setTimeout(() => {
|
const scrollHeight = res.scrollHeight || 99999;
|
||||||
this.scrollTop = targetScrollTop + 1;
|
const targetScrollTop = scrollHeight + 1000;
|
||||||
|
|
||||||
|
// 先重置scrollTop,然后设置到底部
|
||||||
|
this.scrollTop = 0;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollTop = targetScrollTop;
|
||||||
|
console.log('forceScrollToBottom: 设置scrollTop为', targetScrollTop);
|
||||||
|
|
||||||
|
// 延迟再次确保滚动到底部
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.scrollTop = targetScrollTop;
|
const finalScrollTop = (res.scrollHeight || 99999) + 1000;
|
||||||
}, 50);
|
this.scrollTop = finalScrollTop;
|
||||||
}, 100);
|
console.log('forceScrollToBottom: 延迟设置scrollTop完成', finalScrollTop);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('forceScrollToBottom: 未获取到scrollOffset');
|
||||||
|
// 直接使用备用方案
|
||||||
|
this.scrollTop = 0;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollTop = 99999;
|
||||||
|
console.log('forceScrollToBottom: 使用备用方案1');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}).exec();
|
}).exec();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('滚动到底部失败:', error);
|
console.error('滚动到底部失败:', error);
|
||||||
|
// 备用方案:直接设置一个很大的值
|
||||||
|
this.scrollTop = 0;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollTop = 99999;
|
||||||
|
console.log('forceScrollToBottom: 使用备用方案2');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -620,6 +656,9 @@
|
|||||||
});
|
});
|
||||||
this.inputMessage = '';
|
this.inputMessage = '';
|
||||||
this.saveMessagesToLocal(); // 保存用户消息
|
this.saveMessagesToLocal(); // 保存用户消息
|
||||||
|
|
||||||
|
// 立即滚动到底部显示用户消息
|
||||||
|
this.forceScrollToBottom();
|
||||||
|
|
||||||
// 添加AI消息占位
|
// 添加AI消息占位
|
||||||
const aiIdx = this.messages.push({
|
const aiIdx = this.messages.push({
|
||||||
@@ -703,7 +742,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 最终完成后再次确保滚动到底部
|
// 最终完成后再次确保滚动到底部
|
||||||
this.forceScrollToTop();
|
this.forceScrollToBottom();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('JSON解析失败:', line, e);
|
console.warn('JSON解析失败:', line, e);
|
||||||
@@ -741,13 +780,22 @@
|
|||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const query = uni.createSelectorQuery().in(this);
|
try {
|
||||||
query.select('.message-list').boundingClientRect(rect => {
|
const query = uni.createSelectorQuery().in(this);
|
||||||
if (rect) {
|
query.select('.message-list').scrollOffset(res => {
|
||||||
// 确保滚动到最底部
|
if (res && res.scrollHeight) {
|
||||||
this.scrollTop = rect.scrollHeight;
|
// 使用大值确保滚动到最底部
|
||||||
}
|
this.scrollTop = res.scrollHeight + 1000;
|
||||||
}).exec();
|
} else {
|
||||||
|
// 备用方案
|
||||||
|
this.scrollTop = 99999;
|
||||||
|
}
|
||||||
|
}).exec();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('滚动失败:', error);
|
||||||
|
// 备用方案
|
||||||
|
this.scrollTop = 99999;
|
||||||
|
}
|
||||||
}, isStreaming ? 50 : 100);
|
}, isStreaming ? 50 : 100);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
758
pages/aiChat/simple_chat.vue
Normal file
758
pages/aiChat/simple_chat.vue
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
<template>
|
||||||
|
<view class="chat-container">
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" :scroll-with-animation="scrollWithAnimation"
|
||||||
|
:scroll-into-view="scrollIntoView" @scroll="onScroll">
|
||||||
|
|
||||||
|
<!-- 加载提示 -->
|
||||||
|
<view v-if="isLoadingHistory" class="loading-history">
|
||||||
|
<text>正在加载历史记录...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 没有更多历史记录提示 -->
|
||||||
|
<view v-if="!hasMoreHistory && messages.length > 0" class="no-more-history">
|
||||||
|
<text>没有更多历史记录了</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<view v-for="(item, index) in messages" :key="item.messageId || index" :id="'msg-' + item.messageId"
|
||||||
|
:class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
|
||||||
|
|
||||||
|
<!-- 头像 -->
|
||||||
|
<image class="avatar" :src="item.avatar"></image>
|
||||||
|
|
||||||
|
<!-- 消息内容 -->
|
||||||
|
<view class="message-content">
|
||||||
|
<!-- AI消息支持Markdown渲染 -->
|
||||||
|
<view v-if="item.content && item.sender === 'ai'" class="markdown-content"
|
||||||
|
v-html="renderMarkdown(item.content)"></view>
|
||||||
|
<!-- 用户消息纯文本 -->
|
||||||
|
<text v-else-if="item.content">{{ item.content }}</text>
|
||||||
|
|
||||||
|
<!-- AI操作区域 -->
|
||||||
|
<view v-if="item.sender === 'ai'" class="ai-actions">
|
||||||
|
<text class="ai-text">回答由AI生成</text>
|
||||||
|
<view class="icon-group">
|
||||||
|
<img src="/static/good.svg"
|
||||||
|
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'like' }]"
|
||||||
|
@click="handleThumbUp(item.messageId, index)" />
|
||||||
|
<img src="/static/tread.svg"
|
||||||
|
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'dislike' }]"
|
||||||
|
@click="handleThumbDown(item.messageId, index)" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 输入框区域 -->
|
||||||
|
<view class="input-container">
|
||||||
|
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send"
|
||||||
|
:disabled="sending" />
|
||||||
|
<button class="send-button" @click="sendMessage" :disabled="sending || !inputMessage.trim()">
|
||||||
|
{{ sending ? '发送中...' : '发送' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createChatStream } from '@/utils/ai_stream.js';
|
||||||
|
import { sendFeedback, getHistory } from '@/api/aiChat/ai_index.js';
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// Markdown配置
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// DOMPurify安全配置
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||||
|
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// 基础数据
|
||||||
|
inputMessage: '',
|
||||||
|
messages: [],
|
||||||
|
scrollTop: 0,
|
||||||
|
sending: false,
|
||||||
|
|
||||||
|
// 滚动控制
|
||||||
|
scrollWithAnimation: true,
|
||||||
|
scrollIntoView: '',
|
||||||
|
lastScrollTop: 0,
|
||||||
|
loadThreshold: 200, // 距离顶部200px时开始加载
|
||||||
|
loadDebounceTimer: null, // 加载防抖定时器
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
user: uni.getStorageSync('stuNo') || '',
|
||||||
|
userId: uni.getStorageSync('stuId') || '',
|
||||||
|
userName: uni.getStorageSync('stuName') || '',
|
||||||
|
|
||||||
|
// 对话相关
|
||||||
|
conversation_id: null,
|
||||||
|
currentCancel: null,
|
||||||
|
|
||||||
|
// 分页加载
|
||||||
|
isLoadingHistory: false,
|
||||||
|
hasMoreHistory: true,
|
||||||
|
earliestMessageId: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
// 检查登录状态
|
||||||
|
if (!this.user) {
|
||||||
|
this.showToast('请先登录');
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' });
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取对话ID
|
||||||
|
this.conversation_id = uni.getStorageSync('conversation_id') || null;
|
||||||
|
|
||||||
|
// 初始化聊天
|
||||||
|
this.initChat();
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
// 清理防抖定时器
|
||||||
|
if (this.loadDebounceTimer) {
|
||||||
|
clearTimeout(this.loadDebounceTimer);
|
||||||
|
this.loadDebounceTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 初始化聊天 - 获取历史记录
|
||||||
|
*/
|
||||||
|
async initChat() {
|
||||||
|
try {
|
||||||
|
const res = await getHistory({
|
||||||
|
user: this.user,
|
||||||
|
conversationId: this.conversation_id || '',
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.code === 200 && res.data && Array.isArray(res.data.data)) {
|
||||||
|
const newMessages = [];
|
||||||
|
|
||||||
|
// 处理历史消息
|
||||||
|
res.data.data.forEach(msg => {
|
||||||
|
// 用户消息
|
||||||
|
if (msg.query) {
|
||||||
|
newMessages.push({
|
||||||
|
sender: 'user',
|
||||||
|
avatar: '/static/yonghu.png',
|
||||||
|
content: msg.query,
|
||||||
|
messageId: msg.id,
|
||||||
|
conversationId: msg.conversation_id,
|
||||||
|
created_at: msg.created_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI消息
|
||||||
|
if (msg.answer) {
|
||||||
|
newMessages.push({
|
||||||
|
sender: 'ai',
|
||||||
|
avatar: '/static/AI.png',
|
||||||
|
content: msg.answer,
|
||||||
|
messageId: 'ai-' + msg.id,
|
||||||
|
conversationId: msg.conversation_id,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
feedback: msg.feedback || null // 添加反馈状态
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按时间排序(从旧到新)
|
||||||
|
newMessages.sort((a, b) => {
|
||||||
|
return a.created_at - b.created_at;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.messages = newMessages;
|
||||||
|
|
||||||
|
if (newMessages.length > 0) {
|
||||||
|
this.conversation_id = newMessages[0].conversationId;
|
||||||
|
uni.setStorageSync('conversation_id', this.conversation_id);
|
||||||
|
|
||||||
|
// 设置最早的用户消息ID用于分页
|
||||||
|
const userMessages = newMessages.filter(msg => msg.sender === 'user');
|
||||||
|
if (userMessages.length > 0) {
|
||||||
|
this.earliestMessageId = userMessages[0].messageId;
|
||||||
|
} else {
|
||||||
|
// 如果没有用户消息,使用第一个消息的原始ID
|
||||||
|
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasMoreHistory = res.data.has_more || false;
|
||||||
|
} else {
|
||||||
|
// 没有历史记录,显示欢迎消息
|
||||||
|
this.messages = [{
|
||||||
|
sender: 'ai',
|
||||||
|
avatar: '/static/AI.png',
|
||||||
|
content: '你好!我是智水AI辅导员,有什么可以帮助你的吗?',
|
||||||
|
messageId: 'welcome-' + Date.now()
|
||||||
|
}];
|
||||||
|
this.hasMoreHistory = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
this.$nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
// 初始化滚动位置记录
|
||||||
|
this.lastScrollTop = 99999; // 设置为底部位置
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化聊天失败:', error);
|
||||||
|
this.showToast('加载历史记录失败');
|
||||||
|
// 显示欢迎消息
|
||||||
|
this.messages = [{
|
||||||
|
sender: 'ai',
|
||||||
|
avatar: '/static/AI.png',
|
||||||
|
content: '你好!我是智水AI辅导员,有什么可以帮助你的吗?',
|
||||||
|
messageId: 'welcome-' + Date.now()
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载更多历史记录
|
||||||
|
*/
|
||||||
|
async loadMoreHistory() {
|
||||||
|
if (this.isLoadingHistory || !this.hasMoreHistory || !this.conversation_id || !this.earliestMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoadingHistory = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 记录当前第一个可见消息的ID作为锚点
|
||||||
|
const anchorMessageId = this.messages.length > 0 ? this.messages[0].messageId : null;
|
||||||
|
|
||||||
|
const res = await getHistory({
|
||||||
|
user: this.user,
|
||||||
|
conversationId: this.conversation_id,
|
||||||
|
limit: 10,
|
||||||
|
beforeId: this.earliestMessageId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.code === 200 && res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
|
||||||
|
const newMessages = [];
|
||||||
|
|
||||||
|
// 处理新获取的消息
|
||||||
|
res.data.data.forEach(msg => {
|
||||||
|
// 用户消息
|
||||||
|
if (msg.query) {
|
||||||
|
newMessages.push({
|
||||||
|
sender: 'user',
|
||||||
|
avatar: '/static/yonghu.png',
|
||||||
|
content: msg.query,
|
||||||
|
messageId: msg.id,
|
||||||
|
conversationId: msg.conversation_id,
|
||||||
|
created_at: msg.created_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI消息
|
||||||
|
if (msg.answer) {
|
||||||
|
newMessages.push({
|
||||||
|
sender: 'ai',
|
||||||
|
avatar: '/static/AI.png',
|
||||||
|
content: msg.answer,
|
||||||
|
messageId: 'ai-' + msg.id,
|
||||||
|
conversationId: msg.conversation_id,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
feedback: msg.feedback || null // 添加反馈状态
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按时间排序(从旧到新)
|
||||||
|
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('反馈失败');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示提示
|
||||||
|
*/
|
||||||
|
showToast(title, icon = 'none') {
|
||||||
|
uni.showToast({ title, icon });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 整体容器 */
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 原来是 height: 100vh; 改为: */
|
||||||
|
height: calc(100vh - var(--window-top) - var(--window-bottom));
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息列表 */
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
/* height: 300rpx; <- 删除这行 */
|
||||||
|
min-height: 0;
|
||||||
|
/* 关键:允许在 flex 布局里被压缩从而出现滚动 */
|
||||||
|
padding: 20rpx 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载提示 */
|
||||||
|
.loading-history {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-history {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息项 */
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message {
|
||||||
|
flex-direction: row;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像 */
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息内容 */
|
||||||
|
.message-content {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .message-content {
|
||||||
|
background-color: #e1f5fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message .message-content {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI操作区域 */
|
||||||
|
.ai-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-text {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-active {
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(1.2) saturate(1.5);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框区域 */
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom) + 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container input {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #007aff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</workbench>
|
|
||||||
|
|
||||||
<!-- AI 悬浮按钮 -->
|
<!-- AI 悬浮按钮 -->
|
||||||
<view class="ai-hover" @click="toAI">
|
<view class="ai-hover" @click="toAI">
|
||||||
@@ -288,10 +287,10 @@
|
|||||||
getImgUrl(name) {
|
getImgUrl(name) {
|
||||||
return require('../../static/images/workbench/' + name + '.png');
|
return require('../../static/images/workbench/' + name + '.png');
|
||||||
},
|
},
|
||||||
|
//跳转到AI
|
||||||
toAI() {
|
toAI() {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages/aiChat/ai_index'
|
url: '/pages/aiChat/simple_chat'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -65,12 +65,13 @@
|
|||||||
/* ============= 消息列表(可滚动区域) ============= */
|
/* ============= 消息列表(可滚动区域) ============= */
|
||||||
.message-list {
|
.message-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
/* 设置固定高度,为输入框和导航栏预留空间 */
|
||||||
padding: 16px 0 calc(env(safe-area-inset-bottom) + 80px) 0;
|
padding: 16px 0 calc(env(safe-area-inset-bottom) + 80px) 0;
|
||||||
/* 增加底部内边距 */
|
/* 增加底部内边距 */
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
/* 移除overflow-y: auto,因为scroll-view自己处理滚动 */
|
||||||
/* 确保内容超出时可滚动 */
|
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
/* 增加iOS设备上的滚动流畅度 */
|
/* 增加iOS设备上的滚动流畅度 */
|
||||||
}
|
}
|
||||||
@@ -199,6 +200,30 @@
|
|||||||
/* 增加图标之间的间距 */
|
/* 增加图标之间的间距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background-color: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:hover {
|
||||||
|
background-color: #337ecc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:active {
|
||||||
|
background-color: #2b6cb0;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
color: blue;
|
color: blue;
|
||||||
|
|||||||
Reference in New Issue
Block a user