Files
geekai/web/src/components/ChatReplyLine.vue
2025-09-17 16:04:04 +08:00

451 lines
12 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.

<template>
<div class="chat-reply">
<div class="chat-line chat-line-reply-list">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-item">
<div
class="content-wrapper"
v-html="md.render(processContent(data.content.text))"
v-if="data.content.text"
></div>
<div class="content-wrapper flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
</div>
<div
class="flex text-gray-500 text-sm py-2 items-center space-x-2"
v-if="data.created_at"
>
<span class="flex items-center">
<i class="iconfont icon-clock mr-1"></i> {{ dateFormat(data.created_at) }}</span
>
<span class="flex items-center">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="top">
<i
class="iconfont icon-copy cursor-pointer !text-sm"
:data-clipboard-text="data.content"
>
</i>
</el-tooltip>
</span>
</div>
</div>
</div>
</div>
<audio ref="audio" @ended="isPlaying = false" />
</div>
</template>
<script setup>
import { useSharedStore } from '@/store/sharedata'
import { httpPost } from '@/utils/http'
import { dateFormat, processContent } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { nextTick, onMounted, reactive, ref, watchEffect } from 'vue'
import Thinking from './Thinking.vue'
// eslint-disable-next-line no-undef,no-unused-vars
const props = defineProps({
data: {
type: Object,
default: {
type: 'text',
icon: '',
content: {
text: '',
files: [],
},
created_at: '',
tokens: 0,
},
},
readOnly: {
type: Boolean,
default: false,
},
listStyle: {
type: String,
default: 'list',
},
})
const audio = ref(null)
const isPlaying = ref(false)
const playIcon = ref('/images/voice.gif')
const store = useSharedStore()
// 添加代码块展开/收起状态管理
const codeBlockStates = reactive({})
const md = new MarkdownIt({
breaks: true,
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮和展开/收起按钮
const copyBtn = `<div class="flex">
<span class="text-[12px] mr-2 text-[#00e0e0] cursor-pointer expand-btn" data-code-id="${codeIndex}" onclick="window.toggleCodeBlock('${codeIndex}')">收起</span>
<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
</div><textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
'&lt;/textarea>'
)}</textarea>`
let langHtml = ''
let preCode = ''
// 处理代码高亮
if (lang && hl.getLanguage(lang)) {
langHtml = `<span class="lang-name">${lang}</span>`
preCode = hl.highlight(str, { language: lang }).value
} else {
preCode = md.utils.escapeHtml(str)
}
// 将代码包裹在 pre 中,添加展开状态的类(默认展开)
return `<pre class="code-container flex flex-col code-expanded" data-code-id="${codeIndex}">
<div class="flex justify-between bg-[#50505a] w-full rounded-tl-[10px] rounded-tr-[10px] px-3 py-1">${langHtml}${copyBtn}</div>
<code class="language-${lang} hljs">${preCode}</code>
<span class="copy-code-btn absolute right-3 bottom-3" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span></pre>`
},
})
md.use(mathjaxPlugin)
md.use(emoji)
const emits = defineEmits(['regen'])
if (!props.data.icon) {
props.data.icon = 'images/gpt-icon.png'
}
const synthesis = (text) => {
isPlaying.value = true
httpPost('/api/chat/tts', { text: text, model_id: store.ttsModel }, { responseType: 'blob' })
.then((response) => {
// 创建 Blob 对象,明确指定 MIME 类型
const blob = new Blob([response], { type: 'audio/mpeg' }) // 假设音频格式为 MP3
const audioUrl = URL.createObjectURL(blob)
// 播放音频
audio.value.src = audioUrl
audio.value
.play()
.then(() => {
// 播放完成后释放 URL
URL.revokeObjectURL(audioUrl)
})
.catch(() => {
ElMessage.error('音频播放失败,请检查浏览器是否支持该音频格式')
isPlaying.value = false
})
})
.catch((e) => {
ElMessage.error('语音合成失败:' + e.message)
isPlaying.value = false
})
}
const stopSynthesis = () => {
isPlaying.value = false
audio.value.pause()
audio.value.currentTime = 0
}
// 重新生成
const reGenerate = (messageId) => {
emits('regen', messageId)
}
// 添加代码块展开/收起功能
const toggleCodeBlock = (codeId) => {
const codeContainer = document.querySelector(`pre[data-code-id="${codeId}"]`)
const expandBtn = document.querySelector(`.expand-btn[data-code-id="${codeId}"]`)
if (codeContainer && expandBtn) {
if (codeContainer.classList.contains('code-collapsed')) {
codeContainer.classList.remove('code-collapsed')
codeContainer.classList.add('code-expanded')
expandBtn.textContent = '收起'
} else {
codeContainer.classList.remove('code-expanded')
codeContainer.classList.add('code-collapsed')
expandBtn.textContent = '展开'
}
}
}
// 将函数暴露到全局作用域
window.toggleCodeBlock = toggleCodeBlock
// 添加事件监听
onMounted(() => {
nextTick(() => {
setupCodeBlockEvents()
})
})
// 监听内容变化,重新绑定事件
watchEffect(() => {
if (props.data.content.text) {
nextTick(() => {
// 延迟一点时间确保DOM完全渲染
setTimeout(() => {
setupCodeBlockEvents()
}, 100)
})
}
})
const setupCodeBlockEvents = () => {
// 检查所有代码块并设置展开按钮的显示状态
const expandBtns = document.querySelectorAll('.expand-btn')
expandBtns.forEach((btn) => {
const codeId = btn.getAttribute('data-code-id')
const codeContainer = document.querySelector(`pre[data-code-id="${codeId}"]`)
const codeElement = codeContainer?.querySelector('.hljs')
if (codeElement) {
// 临时移除高度限制来获取真实高度
const originalMaxHeight = codeElement.style.maxHeight
codeElement.style.maxHeight = 'none'
const realHeight = codeElement.scrollHeight
codeElement.style.maxHeight = originalMaxHeight
// 如果代码块高度小于等于200px隐藏展开按钮
if (realHeight <= 200) {
btn.style.display = 'none'
// 移除收起状态的类,让短代码块完全展示
codeContainer.classList.remove('code-collapsed')
codeContainer.classList.add('code-expanded')
} else {
btn.style.display = 'inline'
// 确保长代码块默认展开
if (
!codeContainer.classList.contains('code-expanded') &&
!codeContainer.classList.contains('code-collapsed')
) {
codeContainer.classList.add('code-expanded')
}
}
}
})
}
</script>
<style lang="scss">
@use '@/assets/css/markdown/vue.css' as *;
.chat-page,
.chat-export {
--font-family: Menlo, '微软雅黑', 'Roboto Mono', 'Courier New', Courier, monospace, 'Inter',
sans-serif;
font-family: var(--font-family);
.chat-line {
.boxed {
border: 1px solid var(--el-border-color);
border-radius: 5px;
padding: 0 5px;
}
.chat-item {
.content-wrapper {
img {
max-width: 600px;
border-radius: 10px;
}
p {
line-height: 1.5;
code {
color: var(--theme-text-color-primary);
font-weight: 600;
}
}
p:last-child {
margin-bottom: 0;
}
p:first-child {
margin-top: 0;
}
.code-container {
background-color: #2b2b2b;
border-radius: 10px;
position: relative;
.hljs {
border-radius: 10px;
width: 100%;
}
.copy-code-btn {
cursor: pointer;
font-size: 12px;
color: #c1c1c1;
&:hover {
color: #20a0ff;
}
}
}
// 添加代码块展开/收起样式
.code-collapsed {
.hljs {
max-height: 200px;
overflow: hidden;
position: relative;
transition: max-height 0.3s ease;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #2b2b2b);
pointer-events: none;
}
}
}
.code-expanded {
.hljs {
max-height: none;
overflow: auto;
transition: max-height 0.3s ease;
&::after {
display: none;
}
}
}
.expand-btn {
transition: color 0.2s ease;
&:hover {
color: #20a0ff !important;
}
}
.lang-name {
color: #00e0e0;
}
// 设置表格边框
table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
border: 1px solid #dee2e6;
background-color: var(--chat-content-bg);
color: var(--theme-text-color-primary);
thead {
th {
border: 1px solid #dee2e6;
vertical-align: bottom;
border-bottom: 2px solid #dee2e6;
padding: 10px;
}
}
td {
border: 1px solid #dee2e6;
padding: 10px;
}
}
// 代码快
blockquote {
margin: 0 0 0.8rem 0;
background-color: var(--quote-bg-color);
padding: 0.8rem 1.5rem;
color: var(--quote-text-color);
border-left: 0.4rem solid #6b50e1; /* 紫色边框 */
font-size: 16px;
line-height: 1.6;
}
}
}
}
.chat-line-reply-list {
justify-content: center;
background-color: var(--chat-content-bg);
color: var(--theme-text-color-primary);
width: 100%;
padding-bottom: 0.5rem;
padding-top: 1rem;
padding-right: 1rem;
border: 1px solid var(--el-border-color);
border-radius: 10px;
.chat-line-inner {
display: flex;
width: 100%;
max-width: 900px;
padding-left: 10px;
.chat-icon {
margin-right: 20px;
img {
width: 36px;
height: 36px;
border-radius: 50%;
padding: 1px;
}
}
.chat-item {
width: 100%;
position: relative;
padding: 0;
overflow: hidden;
.content-wrapper {
min-height: 20px;
word-break: break-word;
padding: 0;
color: var(--theme-text-color-primary);
font-size: var(--content-font-size);
border-radius: 5px;
overflow: auto;
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
}
}
.tool-box {
font-size: 16px;
.el-button {
height: 20px;
padding: 5px 2px;
}
}
}
}
}
</style>