571 lines
14 KiB
Vue
571 lines
14 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>
|