init
This commit is contained in:
570
src/views/knowledge/aiFlow/components/ChatMessage.vue
Normal file
570
src/views/knowledge/aiFlow/components/ChatMessage.vue
Normal file
@@ -0,0 +1,570 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user