Files
zhxg_app/pages/aiChat/ai_index.vue
2025-08-13 09:19:28 +08:00

457 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- pages/aiChat/ai_index -->
<template>
<view class="chat-container">
<!-- 状态保持当AI聊天可见时才显示聊天内容 -->
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- 自定义导航栏 -->
<view class="custom-nav-bar">
<!-- 左侧历史记录图标触发抽屉 -->
<view class="nav-left" @click="toggleHistoryDrawer">
<image src="/static/history.svg" mode="aspectFit" class="nav-icon"></image>
</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>
<!-- 消息列表 -->
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation>
<block v-for="(item, index) in messages" :key="index">
<view :class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
<!-- 用户/AI头像 -->
<image class="avatar" :src="item.avatar"></image>
<view class="message-content">
<!-- 文字内容 -->
<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>
<!-- 图片内容 -->
<image v-if="item.image" :src="item.image" class="sent-image"></image>
<!-- AI 特有内容 -->
<view v-if="item.sender === 'ai'" class="ai-hint">
<!-- 引用来源部分 -->
<view v-if="item.retrieverResources && item.retrieverResources.length"
class="reference-section">
<view class="reference-section">
<text class="reference-title">引用来源</text>
<!-- 遍历每个引用资源 -->
<view v-for="(ref, idx) in item.retrieverResources" :key="idx"
class="reference-item-wrapper">
<!-- 可点击的文档名 -->
<text class="doc-name-link" @click="toggleSingleReference(index, idx)">
{{ ref.document_name }}
</text>
<!-- 展开的详情仅当前项 -->
<view v-if="showSingleReference[index] && showSingleReference[index][idx]"
class="reference-details-item">
<text class="reference-meta">{{ ref.name }}{{ ref.document_name }}</text>
<text class="reference-content" v-if="ref.content">{{ ref.content }}</text>
</view>
</view>
</view>
</view>
<!-- AI操作区域点赞/点踩 -->
<view class="ai-actions">
<text class="ai-text">回答由AI生成</text>
<view class="icon-group">
<img src="/static/good.svg" class="btn-icon"
@click="handleThumbUpClick(item.messageId)" />
<img src="/static/tread.svg" class="btn-icon"
@click="handleThumbDownClick(item.messageId)" />
</view>
</view>
</view>
</view>
</view>
</block>
</scroll-view>
<!-- 输入框和发送按钮 -->
<view class="input-container">
<!-- 历史记录抽屉组件 -->
<HistoryDrawer :visible="showHistoryDrawer" @close="toggleHistoryDrawer" @item-click="onHistoryItemClick" />
<!-- 消息输入框 -->
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send" />
<!-- 添加图片按钮 -->
<img src="/static/add.svg" class="add-icon" @click="selectImage" />
<!-- 发送消息按钮 -->
<img src="/static/send.svg" class="send-icon" @click="sendMessage" />
</view>
<!-- 悬浮按钮固定在右下角 -->
<view class="ai-hover" @click="goHome">
<view class="ai-hover-content">
<text class="ai-hover-text">AI</text>
</view>
</view>
</view>
</template>
<script>
/* ========== 依赖 ========== */
import HistoryDrawer from '@/components/aiChat/HistoryDrawer.vue'; // 历史记录抽屉组件
import {
createChatStream
} from '@/utils/ai_stream.js'; // 流式聊天API
import MarkdownIt from 'markdown-it'; // Markdown解析器
import DOMPurify from 'dompurify'; // HTML净化器
/* DOMPurify 白名单加固 */
DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
});
// 初始化Markdown解析器
const md = new MarkdownIt({
html: true,
linkify: 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 {
components: {
HistoryDrawer
},
data() {
return {
showHistoryDrawer: false, // 是否显示历史记录抽屉
inputMessage: '', // 用户输入的消息
messages: [], // 聊天消息列表
scrollTop: 0, // 滚动位置
conversation_id: null, // 当前会话ID
showSingleReference: {}, // 控制引用来源的显示状态
currentReader: null, // 当前流式读取器
/* 新增锁 & 基础信息 */
sending: false, // 是否正在发送消息
user: uni.getStorageSync('stuNo') || '', // 用户学号
userId: uni.getStorageSync('stuId') || '', // 用户ID
userName: uni.getStorageSync('stuName') || '' // 用户名
};
},
/* ---------- 生命周期 ---------- */
onLoad() {
// 页面加载时检查登录状态
if (!this.user) {
this.$toast('请先登录');
setTimeout(() => uni.navigateTo({
url: '/pages/login/index'
}), 1500);
return;
}
this.initChat(); // 初始化聊天
},
onUnload() {
// 页面卸载时取消流式读取
this.currentReader?.cancel?.('页面卸载');
this.currentReader = null;
},
async onPullDownRefresh() {
// 下拉刷新
await this.initChat();
uni.stopPullDownRefresh();
this.$toast('刷新成功', 'success');
},
/* ---------- 方法 ---------- */
methods: {
// 返回首页
goHome() {
uni.reLaunch({
url: '/pages/index/index'
});
},
// 切换历史记录抽屉显示状态
toggleHistoryDrawer() {
this.showHistoryDrawer = !this.showHistoryDrawer;
},
// 渲染Markdown内容
renderMarkdown(text) {
return DOMPurify.sanitize(md.render(text || ''));
},
/* 轻量 toast 封装 */
$toast(title, icon = 'none') {
uni.showToast({
title,
icon
});
},
/* 获取历史会话 or 欢迎语 */
async initChat() {
if (!this.user) return this.initConversation();
try {
// 请求获取用户历史消息
const res = await uni.request({
url: BASE_URL + '/getMessagesToUser',
method: 'POST',
data: {
user: this.user
}
});
const data = res.data || {};
if (data.code === 200 && data.data?.conversationId) {
// 设置会话ID和消息列表
this.conversation_id = data.data.conversationId;
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();
}
},
/* 欢迎语兜底 */
welcomeMessage() {
return [{
sender: 'ai',
avatar: '/static/AI.png',
content: '你好!我是您的 AI 小助手,有什么可以帮您?😊',
retrieverResources: [],
image: ''
}];
},
// 初始化新会话
initConversation() {
uni.removeStorageSync('chatHistory');
this.conversation_id = null;
this.messages = this.welcomeMessage();
},
/* 发送消息(带并发锁) */
async sendMessage() {
const msg = this.inputMessage.trim();
if (!msg || this.sending) return;
this.sending = true;
// 添加用户消息到列表
this.messages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: msg,
image: '',
messageId: Date.now().toString()
});
this.inputMessage = '';
// 添加AI占位消息
const aiIdx = this.messages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: '',
retrieverResources: [],
image: '',
messageId: null
}) - 1;
this.$set(this.showSingleReference, aiIdx, {});
this.scrollToBottom();
try {
// 创建流式聊天连接
const {
stream
} = createChatStream({
conversationId: this.conversation_id,
prompt: msg,
user: this.user,
userId: this.userId,
userName: this.userName
});
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 l of lines) {
if (!l.startsWith('data:')) continue;
const text = l.slice(5).trim();
if (text === '[DONE]') continue;
try {
const json = JSON.parse(text);
// 处理消息内容
if (json.event === 'message' && json.answer) {
this.$set(this.messages[aiIdx], 'content', this.messages[aiIdx].content + json
.answer);
this.scrollToBottom();
}
// 处理消息结束事件
if (json.event === 'message_end') {
if (json['conversation id']) this.conversation_id = json['conversation id'];
if (json.metadata?.retriever_resources) {
this.$set(this.messages[aiIdx], 'retrieverResources', json.metadata
.retriever_resources);
this.$set(this.messages[aiIdx], 'messageId', json.message_id || Date.now()
.toString());
}
}
} catch (e) {
console.warn('JSON 解析失败', text, e);
}
}
}
} catch (e) {
console.error('流式请求失败', e);
this.$set(this.messages[aiIdx], 'content', '抱歉AI 回复失败,请重试。');
} finally {
this.sending = false;
this.scrollToBottom();
uni.setStorageSync('chatHistory', this.messages);
}
},
/* 滚动到底部 */
scrollToBottom() {
this.$nextTick(() => {
uni.createSelectorQuery()
.in(this)
.select('.message-list')
.boundingClientRect(rect => {
this.scrollTop = (rect?.height || 0) + 9999;
})
.exec();
});
},
// 切换单个引用来源的显示状态
toggleSingleReference(msgIdx, refIdx) {
if (!this.showSingleReference[msgIdx]) this.$set(this.showSingleReference, msgIdx, {});
const cur = this.showSingleReference[msgIdx][refIdx];
this.$set(this.showSingleReference[msgIdx], refIdx, !cur);
},
// 点赞消息处理
handleThumbUpClick(id) {
if (!id) return;
uni.request({
url: BASE_URL + '/feedback',
method: 'POST',
data: {
messageId: id,
action: 1
}
}).then(() => this.$toast('点赞成功', 'success'))
.catch(() => this.$toast('点赞失败'));
},
// 点踩消息处理
handleThumbDownClick(id) {
if (!id) return;
uni.request({
url: BASE_URL + '/feedback',
method: 'POST',
data: {
messageId: id,
action: 0
}
}).then(() => this.$toast('已反馈'))
.catch(() => this.$toast('反馈失败'));
},
// 新建聊天会话
newChat() {
this.messages = [];
this.conversation_id = null;
uni.removeStorageSync('conversation_id');
uni.removeStorageSync('chatHistory');
this.initConversation();
this.$toast('新聊天已开始');
},
/* 选择图片并上传 */
selectImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'], // 压缩
sourceType: ['album', 'camera'],
success: res => {
const temp = res.tempFilePaths[0];
// 上传图片
uni.uploadFile({
url: BASE_URL + '/files/upload',
filePath: temp,
name: 'file',
formData: {
user: this.user,
userId: this.userId,
userName: this.userName
},
success: ({
data
}) => {
try {
const {
url
} = JSON.parse(data);
if (url) {
// 添加图片消息到列表
this.messages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: '',
image: url,
messageId: Date.now().toString()
});
this.scrollToBottom();
}
} catch (e) {
this.$toast('上传解析失败');
}
},
fail: () => this.$toast('上传失败')
});
}
});
}
}
};
</script>
<style scoped>
/* 引入全局样式 */
@import '@/static/scss/ai_index.css';
</style>