154 lines
3.6 KiB
Vue
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>
|