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>
|