Files
sionrui/frontend/app/web-gold/src/components/ChatMessageRendererV2.vue
2026-01-18 18:43:25 +08:00

154 lines
3.6 KiB
Vue

<template>
<div class="w-full h-full">
<div :class="{ 'streaming': isStreaming }">
<div
ref="markdownContainer"
class="markdown-body"
:class="{ 'streaming': isStreaming }"
v-html="renderedContent"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, onMounted } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
import type { ChatMessageRendererV2Props } from '@/services/ai-bridge'
import 'github-markdown-css/github-markdown.css'
const props = withDefaults(defineProps<ChatMessageRendererV2Props>(), {
config: () => ({
enableTypewriter: true,
typewriterSpeed: 10,
enableMarkdown: true,
enableHighlighting: true
})
})
const emit = defineEmits<{
'stream-start': []
'stream-chunk': [chunk: string]
'stream-end': [fullContent: string]
'error': [error: Error]
}>()
const internalContent = ref('')
const renderedContent = ref('')
const isPageVisible = ref(true)
const markdownContainer = ref<HTMLElement | null>(null)
watch(() => props.content, (newContent) => {
if (newContent !== undefined && newContent !== null) {
updateContent(newContent)
}
}, { immediate: true })
watch(() => props.isStreaming, (newValue, oldValue) => {
if (newValue && !oldValue) {
handleStreamStart()
} else if (!newValue && oldValue) {
handleStreamEnd()
}
})
async function updateContent(newContent = '') {
if (props.isStreaming && internalContent.value) {
await updateStreamingContent(newContent)
} else {
await updateStaticContent(newContent)
}
}
async function updateStreamingContent(newFullContent: string) {
const prev = internalContent.value
let delta: string
if (newFullContent.startsWith(prev)) {
delta = newFullContent.slice(prev.length)
} else {
console.warn('[ChatMessageRendererV2] 流式内容乱序,使用完整内容')
delta = newFullContent
internalContent.value = ''
}
internalContent.value += delta
emit('stream-chunk', delta)
await renderContent(internalContent.value)
}
async function updateStaticContent(newContent: string) {
internalContent.value = newContent
await renderContent(newContent)
}
async function renderContent(content: string) {
if (!content) {
renderedContent.value = ''
return
}
try {
if (props.config?.enableMarkdown !== false) {
renderedContent.value = await renderMarkdown(content)
} else {
renderedContent.value = content.replace(/\n/g, '<br>')
}
setTimeout(() => {
scrollToBottom()
}, 0)
} catch (error) {
console.error('[ChatMessageRendererV2] 渲染错误:', error)
renderedContent.value = content
setTimeout(() => {
scrollToBottom()
}, 0)
}
}
function scrollToBottom() {
if (markdownContainer.value) {
markdownContainer.value.scrollTop = markdownContainer.value.scrollHeight
}
}
function handleStreamStart() {
internalContent.value = ''
renderedContent.value = ''
emit('stream-start')
}
function handleStreamEnd() {
emit('stream-end', internalContent.value)
}
function handleVisibilityChange() {
const wasVisible = isPageVisible.value
isPageVisible.value = !document.hidden
if (!wasVisible && isPageVisible.value && props.content) {
updateContent(props.content)
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
handleVisibilityChange()
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
defineExpose({
reset: () => {
internalContent.value = ''
renderedContent.value = ''
}
})
</script>
<style scoped lang="less">
</style>