Files
sionrui/frontend/app/web-gold/src/components/ChatMessageRenderer.vue

178 lines
4.2 KiB
Vue
Raw Normal View History

2025-11-10 23:53:05 +08:00
<template>
<div class="prompt-display" v-html="renderedContent"></div>
</template>
<script setup>
2025-11-13 01:06:28 +08:00
import { ref, watch, onUnmounted } from 'vue'
2025-11-10 23:53:05 +08:00
import { renderMarkdown } from '@/utils/markdown'
const props = defineProps({
content: {
type: String,
default: ''
},
isStreaming: {
type: Boolean,
default: false
}
})
2025-11-13 01:06:28 +08:00
// 当前显示的内容(用于打字机效果)
const displayedContent = ref('')
// 目标内容(完整内容)
const targetContent = ref('')
// 动画帧 ID
let animationFrameId = null
// 打字机速度(字符/帧,可根据性能调整)
const TYPING_SPEED = 3
// 是否正在执行打字机动画
const isTyping = ref(false)
2025-11-10 23:53:05 +08:00
/**
2025-11-13 01:06:28 +08:00
* 高性能打字机效果渲染
* 使用 requestAnimationFrame 实现平滑的逐字符显示
2025-11-10 23:53:05 +08:00
*/
2025-11-13 01:06:28 +08:00
function typewriterEffect() {
if (!isTyping.value) return
const currentLength = displayedContent.value.length
const targetLength = targetContent.value.length
if (currentLength >= targetLength) {
// 已完成,停止动画
isTyping.value = false
displayedContent.value = targetContent.value
return
2025-11-10 23:53:05 +08:00
}
2025-11-13 01:06:28 +08:00
// 每次增加多个字符以提高性能
const nextLength = Math.min(currentLength + TYPING_SPEED, targetLength)
displayedContent.value = targetContent.value.slice(0, nextLength)
// 继续下一帧
animationFrameId = requestAnimationFrame(typewriterEffect)
2025-11-10 23:53:05 +08:00
}
/**
2025-11-13 01:06:28 +08:00
* 开始打字机效果
2025-11-10 23:53:05 +08:00
*/
2025-11-13 01:06:28 +08:00
function startTypewriter() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
2025-11-10 23:53:05 +08:00
}
2025-11-13 01:06:28 +08:00
isTyping.value = true
animationFrameId = requestAnimationFrame(typewriterEffect)
2025-11-10 23:53:05 +08:00
}
/**
2025-11-13 01:06:28 +08:00
* 停止打字机效果
2025-11-10 23:53:05 +08:00
*/
2025-11-13 01:06:28 +08:00
function stopTypewriter() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
2025-11-10 23:53:05 +08:00
}
2025-11-13 01:06:28 +08:00
isTyping.value = false
}
2025-11-10 23:53:05 +08:00
/**
2025-11-13 01:06:28 +08:00
* 计算渲染的 HTML 内容
2025-11-10 23:53:05 +08:00
*/
2025-11-13 01:06:28 +08:00
const renderedContent = ref('')
2025-11-10 23:53:05 +08:00
/**
2025-11-13 01:06:28 +08:00
* 更新渲染内容
2025-11-10 23:53:05 +08:00
*/
2025-11-13 01:06:28 +08:00
function updateRenderedContent() {
const content = displayedContent.value
2025-11-10 23:53:05 +08:00
if (!content) {
2025-11-13 01:06:28 +08:00
renderedContent.value = ''
2025-11-10 23:53:05 +08:00
return
}
2025-11-13 01:06:28 +08:00
// 渲染 markdown 为 HTML
renderedContent.value = renderMarkdown(content)
2025-11-10 23:53:05 +08:00
}
2025-11-13 01:06:28 +08:00
// 监听 displayedContent 变化,更新渲染
watch(displayedContent, () => {
updateRenderedContent()
}, { immediate: true })
2025-11-10 23:53:05 +08:00
/**
* 处理内容更新
*/
2025-11-13 01:06:28 +08:00
function handleContentUpdate(newContent) {
if (!newContent) {
targetContent.value = ''
displayedContent.value = ''
stopTypewriter()
2025-11-10 23:53:05 +08:00
return
}
2025-11-13 01:06:28 +08:00
// 更新目标内容
targetContent.value = newContent
2025-11-10 23:53:05 +08:00
if (props.isStreaming) {
2025-11-13 01:06:28 +08:00
// 流式传输模式:使用打字机效果显示内容
// 流式传输时,内容会逐步到达,使用打字机效果增强体验
const currentLength = displayedContent.value.length
const newLength = newContent.length
if (newLength !== currentLength) {
// 内容发生变化
if (newLength < currentLength) {
// 内容被重置或缩短,直接显示新内容
displayedContent.value = newContent
stopTypewriter()
} else {
// 内容增加,使用打字机效果显示新增部分
if (!isTyping.value) {
startTypewriter()
}
}
}
2025-11-10 23:53:05 +08:00
} else {
2025-11-13 01:06:28 +08:00
// 静态内容模式:直接显示全部内容,不使用打字机效果
displayedContent.value = newContent
stopTypewriter()
2025-11-10 23:53:05 +08:00
}
}
// 监听 content 变化
watch(() => props.content, (newContent) => {
2025-11-13 01:06:28 +08:00
handleContentUpdate(newContent)
2025-11-10 23:53:05 +08:00
}, { immediate: true })
2025-11-13 01:06:28 +08:00
// 监听 isStreaming 变化
2025-11-10 23:53:05 +08:00
watch(() => props.isStreaming, (newVal, oldVal) => {
2025-11-13 01:06:28 +08:00
if (!newVal && oldVal) {
// 流式传输结束,确保显示完整内容,停止打字机效果
if (targetContent.value) {
displayedContent.value = targetContent.value
}
stopTypewriter()
} else if (newVal) {
// 开始流式传输,如果内容有变化,启动打字机效果
// 打字机效果会在 handleContentUpdate 中根据内容变化自动启动
2025-11-10 23:53:05 +08:00
}
})
2025-11-13 01:06:28 +08:00
// 组件卸载时清理
onUnmounted(() => {
stopTypewriter()
})
2025-11-10 23:53:05 +08:00
</script>
<style scoped>
/* 修复 pre 标签撑开容器的问题 */
.prompt-display :deep(pre) {
max-width: 100%;
overflow-x: auto;
word-break: break-word;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>