531 lines
13 KiB
Vue
531 lines
13 KiB
Vue
<template>
|
||
<div class="messages">
|
||
<div v-for="(msg, index) in messages" :key="index" class="message" :class="[msg.role]" @contextmenu.prevent="showContextMenu($event, msg, index)">
|
||
<div class="avatar">
|
||
<img :src="msg.role === 'user' ? currentUserAvatar : botAvatar" alt="avatar" />
|
||
</div>
|
||
<div style="width: 100%">
|
||
<!-- 消息时间显示 - 移到content外面 -->
|
||
<div class="time">{{ getMessageTime(msg) }}</div>
|
||
<div class="content">
|
||
<!-- 思考内容区域,添加可折叠功能,默认展开 -->
|
||
<div class="collapsible_wrapper" v-if="msg.reasoning_content">
|
||
<div class="collapsible_tag" @click="toggleContent(index, 'reasoning')">
|
||
<el-icon class="collapsible-icon" :class="{ 'is-rotate': contentVisible[index]?.reasoning !== false }">
|
||
<ArrowDown />
|
||
</el-icon>
|
||
<span>深度思考</span>
|
||
</div>
|
||
<div class="collapsible_content" v-show="contentVisible[index]?.reasoning !== false">
|
||
{{ msg.reasoning_content }}
|
||
</div>
|
||
</div>
|
||
<template v-if="msg.content">
|
||
<div v-html="parseMarkdown(msg.content)"></div>
|
||
<div class="questions" v-if="msg.questions">
|
||
<el-tag v-for="(question, qIndex) in msg.questions" :key="qIndex" type="primary" @click="handleQuestionClick(question.text)">
|
||
{{ question.text }}
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
<div v-else-if="msg.nodes" class="collapsible_wrapper">
|
||
<div class="collapsible_tag" @click="toggleContent(index, 'nodes')">
|
||
<el-icon class="collapsible-icon" :class="{ 'is-rotate': contentVisible[index]?.nodes !== false }">
|
||
<ArrowDown />
|
||
</el-icon>
|
||
<span>执行步骤</span>
|
||
</div>
|
||
<div class="collapsible_content" v-show="contentVisible[index]?.nodes !== false">
|
||
<node-list :nodes="msg.nodes" />
|
||
</div>
|
||
<div v-if="msg.result" v-html="parseMarkdown(msg.result)"></div>
|
||
</div>
|
||
<template v-if="loading && messages.indexOf(msg) === messages.length - 1">
|
||
<div class="typing-indicator">
|
||
<div class="dot"></div>
|
||
<div class="dot"></div>
|
||
<div class="dot"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 右键菜单 -->
|
||
<el-dialog v-model="editDialogVisible" title="编辑消息" width="50%" :before-close="handleEditDialogClose">
|
||
<el-input v-model="editingContent" type="textarea" :rows="10" placeholder="请输入消息内容" />
|
||
<template #footer>
|
||
<span class="dialog-footer">
|
||
<el-button @click="handleEditDialogClose">取消</el-button>
|
||
<el-button type="primary" @click="saveEditedMessage">确认</el-button>
|
||
</span>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 自定义右键菜单 -->
|
||
<div v-show="contextMenuVisible" class="context-menu" :style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }">
|
||
<div class="context-menu-item" @click="copyMessage">
|
||
<el-icon>
|
||
<Document />
|
||
</el-icon>
|
||
<span>复制</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="editMessage">
|
||
<el-icon>
|
||
<Edit />
|
||
</el-icon>
|
||
<span>编辑</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="deleteMessage">
|
||
<el-icon>
|
||
<Delete />
|
||
</el-icon>
|
||
<span>删除</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="speakMessage">
|
||
<el-icon>
|
||
<Microphone />
|
||
</el-icon>
|
||
<span>朗读</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
|
||
import { marked } from 'marked';
|
||
import hljs from 'highlight.js/lib/core';
|
||
// 按需导入常用的语言
|
||
import javascript from 'highlight.js/lib/languages/javascript';
|
||
import typescript from 'highlight.js/lib/languages/typescript';
|
||
import python from 'highlight.js/lib/languages/python';
|
||
import java from 'highlight.js/lib/languages/java';
|
||
import xml from 'highlight.js/lib/languages/xml';
|
||
import css from 'highlight.js/lib/languages/css';
|
||
import scss from 'highlight.js/lib/languages/scss';
|
||
import json from 'highlight.js/lib/languages/json';
|
||
import bash from 'highlight.js/lib/languages/bash';
|
||
import markdown from 'highlight.js/lib/languages/markdown';
|
||
// 导入暗色主题样式
|
||
import 'highlight.js/styles/atom-one-dark.css';
|
||
// 导入Element Plus图标
|
||
import { ArrowDown, Document, Edit, Delete, Microphone } from '@element-plus/icons-vue';
|
||
import { ElMessage } from 'element-plus';
|
||
|
||
import NodeList from '/@/views/knowledge/aiFlow/components/NodeList.vue';
|
||
import { dateTimeStr } from '/@/utils/formatTime';
|
||
|
||
// 定义组件接收的props
|
||
const props = defineProps({
|
||
loading: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
messages: {
|
||
type: Array,
|
||
required: true,
|
||
},
|
||
currentUserAvatar: {
|
||
type: String,
|
||
default: '/img/chat/icon.png',
|
||
},
|
||
botAvatar: {
|
||
type: String,
|
||
default: '/img/chat/chatgpt.png',
|
||
},
|
||
});
|
||
|
||
// 定义组件触发的事件
|
||
const emit = defineEmits(['item-click', 'change']);
|
||
|
||
// 注册语言
|
||
hljs.registerLanguage('javascript', javascript);
|
||
hljs.registerLanguage('typescript', typescript);
|
||
hljs.registerLanguage('python', python);
|
||
hljs.registerLanguage('java', java);
|
||
hljs.registerLanguage('xml', xml);
|
||
hljs.registerLanguage('css', css);
|
||
hljs.registerLanguage('scss', scss);
|
||
hljs.registerLanguage('json', json);
|
||
hljs.registerLanguage('bash', bash);
|
||
hljs.registerLanguage('markdown', markdown);
|
||
|
||
// 配置marked的代码高亮选项
|
||
onMounted(() => {
|
||
marked.setOptions({
|
||
highlight: function (code, lang) {
|
||
if (lang && hljs.getLanguage(lang)) {
|
||
try {
|
||
return hljs.highlight(code, { language: lang }).value;
|
||
} catch (e) {
|
||
console.error(e);
|
||
return code;
|
||
}
|
||
}
|
||
return hljs.highlightAuto(code).value;
|
||
},
|
||
breaks: true, // 支持换行符
|
||
gfm: true, // 启用GitHub风格Markdown
|
||
sanitize: false, // 允许HTML标签以支持代码高亮
|
||
langPrefix: 'hljs language-', // 添加代码块的class前缀
|
||
});
|
||
});
|
||
|
||
// 使用reactive创建响应式对象,用于跟踪内容的显示状态
|
||
const contentVisible = reactive({});
|
||
|
||
// 右键菜单相关状态
|
||
const contextMenuVisible = ref(false);
|
||
const contextMenuTop = ref(0);
|
||
const contextMenuLeft = ref(0);
|
||
const currentMessage = ref(null);
|
||
const currentMessageIndex = ref(-1);
|
||
|
||
// 编辑对话框相关状态
|
||
const editDialogVisible = ref(false);
|
||
const editingContent = ref('');
|
||
|
||
/**
|
||
* 切换内容的显示/隐藏状态
|
||
* @param {number} index - 消息索引
|
||
* @param {string} type - 内容类型 ('reasoning' 或 'nodes')
|
||
*/
|
||
const toggleContent = (index, type) => {
|
||
if (!contentVisible[index]) {
|
||
contentVisible[index] = {};
|
||
}
|
||
contentVisible[index][type] = contentVisible[index][type] === false ? true : false;
|
||
};
|
||
|
||
/**
|
||
* 获取消息的时间
|
||
* @param {Object} msg - 消息对象
|
||
* @returns {string} - 格式化后的时间字符串
|
||
*/
|
||
const getMessageTime = (msg) => {
|
||
// 如果消息对象中已有时间属性,则直接使用
|
||
if (msg.time) {
|
||
return parseDate(msg.time, dateTimeStr);
|
||
}
|
||
// 否则生成当前时间并赋值给消息对象
|
||
const currentTime = parseDate(new Date(), dateTimeStr);
|
||
// 为消息对象添加时间属性
|
||
msg.time = new Date().toISOString();
|
||
return currentTime;
|
||
};
|
||
|
||
/**
|
||
* 显示右键菜单
|
||
* @param {Event} event - 鼠标事件对象
|
||
* @param {Object} msg - 消息对象
|
||
* @param {number} index - 消息索引
|
||
*/
|
||
const showContextMenu = (event, msg, index) => {
|
||
// 阻止默认右键菜单
|
||
event.preventDefault();
|
||
// 设置菜单位置
|
||
contextMenuTop.value = event.clientY;
|
||
contextMenuLeft.value = event.clientX;
|
||
// 保存当前消息和索引
|
||
currentMessage.value = msg;
|
||
currentMessageIndex.value = index;
|
||
// 显示菜单
|
||
contextMenuVisible.value = true;
|
||
};
|
||
|
||
/**
|
||
* 隐藏右键菜单
|
||
*/
|
||
const hideContextMenu = () => {
|
||
contextMenuVisible.value = false;
|
||
};
|
||
|
||
/**
|
||
* 复制消息内容到剪贴板
|
||
*/
|
||
const copyMessage = () => {
|
||
if (!currentMessage.value) return;
|
||
|
||
// 获取要复制的文本内容
|
||
const textToCopy = currentMessage.value.content || currentMessage.value.reasoning_content || currentMessage.value.result || '';
|
||
|
||
// 使用Clipboard API复制文本
|
||
navigator.clipboard
|
||
.writeText(textToCopy)
|
||
.then(() => {
|
||
ElMessage.success('复制成功');
|
||
})
|
||
.catch((err) => {
|
||
ElMessage.error('复制失败: ' + err);
|
||
});
|
||
|
||
// 隐藏菜单
|
||
hideContextMenu();
|
||
};
|
||
|
||
/**
|
||
* 打开编辑对话框
|
||
*/
|
||
const editMessage = () => {
|
||
if (!currentMessage.value) return;
|
||
|
||
// 设置编辑内容
|
||
editingContent.value = currentMessage.value.content || currentMessage.value.reasoning_content || currentMessage.value.result || '';
|
||
|
||
// 显示编辑对话框
|
||
editDialogVisible.value = true;
|
||
|
||
// 隐藏菜单
|
||
hideContextMenu();
|
||
};
|
||
|
||
/**
|
||
* 关闭编辑对话框
|
||
*/
|
||
const handleEditDialogClose = () => {
|
||
editDialogVisible.value = false;
|
||
editingContent.value = '';
|
||
};
|
||
|
||
/**
|
||
* 保存编辑后的消息
|
||
*/
|
||
const saveEditedMessage = () => {
|
||
if (currentMessageIndex.value === -1 || !currentMessage.value) return;
|
||
|
||
// 更新时间
|
||
currentMessage.value.time = dayjs().toISOString();
|
||
// 更新消息内容
|
||
currentMessage.value.content = editingContent.value;
|
||
|
||
// 发送编辑消息事件
|
||
emit('change');
|
||
|
||
// 关闭对话框
|
||
handleEditDialogClose();
|
||
|
||
ElMessage.success('消息已更新');
|
||
};
|
||
|
||
/**
|
||
* 删除消息
|
||
*/
|
||
const deleteMessage = () => {
|
||
if (currentMessageIndex.value === -1) return;
|
||
|
||
// 从消息数组中删除当前消息
|
||
props.messages.splice(currentMessageIndex.value, 1);
|
||
|
||
// 发送删除消息事件
|
||
emit('change');
|
||
|
||
// 隐藏菜单
|
||
hideContextMenu();
|
||
|
||
ElMessage.success('消息已删除');
|
||
};
|
||
|
||
/**
|
||
* 使用浏览器API朗读消息
|
||
*/
|
||
const speakMessage = () => {
|
||
if (!currentMessage.value) return;
|
||
|
||
// 获取要朗读的文本
|
||
const textToSpeak = currentMessage.value.content || currentMessage.value.reasoning_content || currentMessage.value.result || '';
|
||
|
||
// 检查浏览器是否支持语音合成
|
||
if ('speechSynthesis' in window) {
|
||
// 创建语音合成实例
|
||
const utterance = new SpeechSynthesisUtterance(textToSpeak);
|
||
|
||
// 设置语音属性
|
||
utterance.lang = 'zh-CN'; // 设置语言为中文
|
||
utterance.rate = 1.0; // 设置语速
|
||
utterance.pitch = 1.0; // 设置音调
|
||
|
||
// 开始朗读
|
||
window.speechSynthesis.speak(utterance);
|
||
|
||
ElMessage.success('正在朗读消息');
|
||
} else {
|
||
ElMessage.error('您的浏览器不支持语音合成');
|
||
}
|
||
|
||
// 隐藏菜单
|
||
hideContextMenu();
|
||
};
|
||
|
||
/**
|
||
* 处理问题点击事件
|
||
* @param {string} text - 问题文本
|
||
*/
|
||
const handleQuestionClick = (text) => {
|
||
emit('item-click', text);
|
||
};
|
||
|
||
/**
|
||
* 解析Markdown内容并支持代码高亮
|
||
* @param {string} content - 需要解析的Markdown内容
|
||
* @returns {string} - 解析后的HTML字符串
|
||
*/
|
||
const parseMarkdown = (content) => {
|
||
if (!content) return '';
|
||
return marked(content);
|
||
};
|
||
|
||
// 点击页面其他区域时隐藏右键菜单
|
||
const handleDocumentClick = () => {
|
||
hideContextMenu();
|
||
};
|
||
|
||
// 组件挂载时添加全局点击事件监听
|
||
onMounted(() => {
|
||
document.addEventListener('click', handleDocumentClick);
|
||
});
|
||
|
||
// 组件卸载前移除全局点击事件监听
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('click', handleDocumentClick);
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
// 可折叠内容区域通用样式
|
||
.collapsible_wrapper {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.collapsible_tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0px 8px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: all 0.2s;
|
||
border: 1px solid #e0e0e0;
|
||
margin-bottom: 6px;
|
||
|
||
&:hover {
|
||
background-color: #e8e8e8;
|
||
border-color: #d0d0d0;
|
||
}
|
||
|
||
.collapsible-icon {
|
||
margin-right: 6px;
|
||
transition: transform 0.3s;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
|
||
&.is-rotate {
|
||
transform: rotate(180deg);
|
||
}
|
||
}
|
||
|
||
span {
|
||
font-size: 12px;
|
||
color: #606266;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.collapsible_content {
|
||
position: relative;
|
||
margin: 5px 0;
|
||
padding: 0px 12px;
|
||
box-sizing: border-box;
|
||
font-size: 13px;
|
||
&:before {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
content: ' ';
|
||
height: 100%;
|
||
width: 2px;
|
||
background-color: #e5e5e5;
|
||
}
|
||
}
|
||
|
||
// 右键菜单样式
|
||
.context-menu {
|
||
position: fixed;
|
||
z-index: 9999;
|
||
background: white;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||
padding: 5px 0;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.context-menu-item {
|
||
padding: 8px 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
transition: background-color 0.3s;
|
||
|
||
&:hover {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.el-icon {
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
span {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
:deep(pre) {
|
||
background-color: #282c34; // Atom One Dark 背景色
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
overflow: auto;
|
||
font-size: 14px;
|
||
line-height: 1.45;
|
||
margin-bottom: 16px;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
:deep(code) {
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||
padding: 0.2em 0.4em;
|
||
margin: 0;
|
||
font-size: 85%;
|
||
background-color: #3a404b; // Atom One Dark 次要背景色
|
||
color: #abb2bf; // Atom One Dark 文本颜色
|
||
border-radius: 3px;
|
||
}
|
||
|
||
:deep(pre code) {
|
||
padding: 0;
|
||
margin: 0;
|
||
font-size: 100%;
|
||
word-break: normal;
|
||
white-space: pre;
|
||
background: transparent;
|
||
border: 0;
|
||
color: #abb2bf; // Atom One Dark 文本颜色
|
||
}
|
||
|
||
// 添加代码块的滚动条样式
|
||
:deep(pre)::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
:deep(pre)::-webkit-scrollbar-thumb {
|
||
background: #3a404b;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
:deep(pre)::-webkit-scrollbar-track {
|
||
background: #282c34;
|
||
border-radius: 4px;
|
||
}
|
||
</style>
|