Files
sionrui/frontend/app/web-gold/src/components/ChatMessageRenderer.vue
2025-11-23 01:40:59 +08:00

144 lines
3.8 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="prompt-display" v-html="renderedContent"></div>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted, onMounted } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
const props = defineProps({
content: {
type: String,
default: ''
},
isStreaming: {
type: Boolean,
default: false
}
})
// 内部维护的纯文本内容(用于计算增量和渲染源)
const internalContent = ref('')
// 最终渲染的 HTML
const renderedContent = ref('')
let debounceTimer = null
// 页面可见性状态
const isPageVisible = ref(true)
// 仅在流式时计算并追加增量
function appendStreamingDelta(newFullContent) {
// 页面不可见时,跳过更新避免重复片段
if (!isPageVisible.value) {
console.log('[ChatMessageRenderer] 页面不可见,跳过流式更新')
return
}
const prev = internalContent.value
if (newFullContent.startsWith(prev)) {
// 正常情况:新内容包含旧内容 → 只追加差值
const delta = newFullContent.slice(prev.length)
internalContent.value += delta
} else {
// 异常情况(如后端重发了不连续的内容),直接覆盖防止乱序
console.warn('[ChatMessageRenderer] Streaming content out of order, forcing replace')
internalContent.value = newFullContent
}
}
// 更新 Markdown 渲染
async function updateRendered() {
if (!internalContent.value) {
renderedContent.value = ''
return
}
// renderMarkdown 可能包含异步操作(如 highlight.js用 nextTick 确保 DOM 就绪
renderedContent.value = await renderMarkdown(internalContent.value)
}
// 主更新逻辑
function updateContent(newContent = '') {
if (props.isStreaming) {
appendStreamingDelta(newContent)
} else {
internalContent.value = newContent
}
// 流式直接渲染,非流式防抖(避免频繁完整替换导致光标跳动或闪烁)
if (props.isStreaming) {
updateRendered()
} else {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
updateRendered()
}, 60) // 60ms 足够平滑且不卡顿
}
}
// 监听 content 变化(外部每次推送的都是当前完整内容)
watch(() => props.content, (newVal) => {
updateContent(newVal || '')
}, { immediate: true })
// 当流式开始时清空,防止旧内容残留
watch(() => props.isStreaming, (newVal, oldVal) => {
if (newVal && !oldVal) {
internalContent.value = ''
renderedContent.value = ''
}
})
// 监听页面可见性变化,避免后台时累积重复片段
function handleVisibilityChange() {
const wasVisible = isPageVisible.value
isPageVisible.value = !document.hidden
if (!wasVisible && isPageVisible.value) {
// 页面从不可见变为可见:立即同步最新内容
console.log('[ChatMessageRenderer] 页面重新可见,同步最新内容')
// 使用外部传入的最新content进行同步而不是累积的增量
if (props.content) {
internalContent.value = props.content
updateRendered()
}
} else if (wasVisible && !isPageVisible.value) {
console.log('[ChatMessageRenderer] 页面进入后台')
}
}
onMounted(() => {
// 初始状态
handleVisibilityChange()
// 添加监听器
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
// 移除监听器
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
</script>
<style scoped>
.prompt-display {
line-height: 1.6;
}
/* 代码块优化 */
.prompt-display :deep(pre) {
max-width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
background: #282c34;
color: #abb2bf;
padding: 1em;
border-radius: 8px;
margin: 1em 0;
}
.prompt-display :deep(code) {
font-family: 'Fira Code', Menlo, Monaco, Consolas, 'Courier New', monospace;
}
</style>