947 lines
23 KiB
Vue
947 lines
23 KiB
Vue
<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-hint">
|
||
<!-- 引用来源部分 -->
|
||
<view v-if="item.retrieverResources && item.retrieverResources.length" class="reference-section">
|
||
<text class="reference-title">引用来源:</text>
|
||
<!-- 遍历合并后的引用资源 -->
|
||
<view v-for="(groupedRef, groupIdx) in getGroupedReferences(item.retrieverResources)" :key="groupIdx" class="reference-item-wrapper">
|
||
<!-- 可点击的文档名 -->
|
||
<text class="doc-name-link" @click="toggleSingleReference(index, groupIdx)">
|
||
{{ groupedRef.document_name }}
|
||
</text>
|
||
|
||
<!-- 展开的详情(仅当前项) -->
|
||
<view v-if="showSingleReference[index] && showSingleReference[index][groupIdx]" class="reference-details-item">
|
||
<!-- 分段显示内容 -->
|
||
<view v-for="(ref, refIdx) in groupedRef.references" :key="refIdx" class="reference-segment">
|
||
<text class="reference-meta">{{ ref.name }}({{ ref.document_name }})</text>
|
||
<text class="reference-content" v-if="ref.content">{{ ref.content }}</text>
|
||
<view v-if="refIdx < groupedRef.references.length - 1" class="reference-divider"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- AI操作区域 -->
|
||
<view class="ai-actions">
|
||
<text class="ai-text">AI回答也可能会犯错。请核查重要信息。</text>
|
||
<view class="icon-group">
|
||
<!-- 点赞图标 -->
|
||
<u-icon
|
||
:name="item.feedback && item.feedback.rating === 'like' ? 'thumb-up-fill' : 'thumb-up'"
|
||
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'like' }]"
|
||
@click="handleThumbUp(item.messageId, index)"
|
||
size="20"
|
||
:color="item.feedback && item.feedback.rating === 'like' ? '#007aff' : '#666'"></u-icon>
|
||
<!-- 点踩图标 -->
|
||
<u-icon
|
||
:name="item.feedback && item.feedback.rating === 'dislike' ? 'thumb-down-fill' : 'thumb-down'"
|
||
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'dislike' }]"
|
||
@click="handleThumbDown(item.messageId, index)"
|
||
size="20"
|
||
:color="item.feedback && item.feedback.rating === 'dislike' ? '#ff3b30' : '#666'"></u-icon>
|
||
<!-- 原有的img方式(已注释)
|
||
<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>
|
||
</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,
|
||
|
||
// 引用信息展示控制
|
||
showSingleReference: {}
|
||
};
|
||
},
|
||
|
||
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, // 添加反馈状态
|
||
retrieverResources: msg.retriever_resources || [] // 添加引用资源
|
||
});
|
||
}
|
||
});
|
||
|
||
// 按时间排序(从旧到新)
|
||
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, // 添加反馈状态
|
||
retrieverResources: msg.retriever_resources || [] // 添加引用资源
|
||
});
|
||
}
|
||
});
|
||
|
||
// 按时间排序(从旧到新)
|
||
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('反馈失败');
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 将引用资源按文档名称分组合并
|
||
* @param {Array} references - 原始引用资源数组
|
||
* @returns {Array} 分组后的引用资源数组
|
||
*/
|
||
getGroupedReferences(references) {
|
||
if (!references || !references.length) return [];
|
||
|
||
const grouped = {};
|
||
|
||
// 按文档名称分组
|
||
references.forEach(ref => {
|
||
const docName = ref.document_name;
|
||
if (!grouped[docName]) {
|
||
grouped[docName] = {
|
||
document_name: docName,
|
||
references: []
|
||
};
|
||
}
|
||
grouped[docName].references.push(ref);
|
||
});
|
||
|
||
// 转换为数组
|
||
return Object.values(grouped);
|
||
},
|
||
|
||
/**
|
||
* 切换单个引用的显示状态
|
||
* @param {number} msgIdx - 消息索引
|
||
* @param {number} refIdx - 引用索引
|
||
*/
|
||
toggleSingleReference(msgIdx, refIdx) {
|
||
if (!this.showSingleReference[msgIdx]) {
|
||
this.$set(this.showSingleReference, msgIdx, {});
|
||
}
|
||
const current = this.showSingleReference[msgIdx][refIdx];
|
||
this.$set(this.showSingleReference[msgIdx], refIdx, !current);
|
||
},
|
||
|
||
/**
|
||
* 显示提示
|
||
*/
|
||
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-hint {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
/* 引用来源部分 */
|
||
.reference-section {
|
||
margin-top: 8px;
|
||
margin-bottom: 8px;
|
||
padding: 8px;
|
||
background-color: #f8f9fa;
|
||
border-radius: 6px;
|
||
border-left: 3px solid #007aff;
|
||
}
|
||
|
||
.reference-title {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #333;
|
||
margin-bottom: 6px;
|
||
display: block;
|
||
}
|
||
|
||
/* 每个引用项容器 */
|
||
.reference-item-wrapper {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
/* 可点击文档名 */
|
||
.doc-name-link {
|
||
color: #007aff;
|
||
text-decoration: underline;
|
||
margin-right: 8px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* 引用详情 */
|
||
.reference-details-item {
|
||
margin-top: 6px;
|
||
padding: 8px;
|
||
background-color: #f9f9f9;
|
||
border-radius: 4px;
|
||
border: 1px solid #e0e0e0;
|
||
font-size: 11px;
|
||
color: #555;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.reference-meta {
|
||
font-weight: bold;
|
||
color: #333;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.reference-content {
|
||
color: #444;
|
||
line-height: 1.6;
|
||
font-size: 10px;
|
||
}
|
||
|
||
/* 引用分段样式 */
|
||
.reference-segment {
|
||
margin-bottom: 12px;
|
||
padding: 8px;
|
||
background-color: #fafafa;
|
||
border-left: 3px solid #007aff;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.reference-segment:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* 分段分隔线 */
|
||
.reference-divider {
|
||
height: 2px;
|
||
background: linear-gradient(to right, #e8e8e8, #f5f5f5, #e8e8e8);
|
||
margin: 12px 0;
|
||
width: 100%;
|
||
border-radius: 1px;
|
||
}
|
||
|
||
/* AI操作区域 */
|
||
.ai-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.ai-text {
|
||
color: #999;
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
flex: 1;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.icon-group {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* 图标按钮样式 - 适配u-icon组件 */
|
||
.btn-icon {
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
opacity: 0.6;
|
||
margin: 0 4px;
|
||
}
|
||
|
||
.btn-icon-active {
|
||
opacity: 1;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* 针对u-icon组件的特殊样式 */
|
||
.btn-icon.u-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 激活状态下的颜色变化 */
|
||
.btn-icon-active.u-icon {
|
||
color: #007aff !important;
|
||
}
|
||
|
||
/* 输入框区域 */
|
||
.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: 6px 12px;
|
||
background-color: #007aff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 16px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.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> |