AI 对话实现打字机效果 Vue3 setup
本文介绍了如何实现AI对话页面的打字机效果。通过setInterval逐字显示AI的最后一条回复,而历史记录则直接显示完整内容。代码展示了聊天窗口的数据绑定、样式处理和核心的typeEffect函数实现,该函数以100毫秒间隔更新文字,并支持完成后回调。关键点包括:仅对AI最后回复应用打字效果、响应式更新消息数组、以及发送消息时的状态管理。整体方案简洁高效,适用于类似ChatGPT的交互场景。
·
AI 对话实现打字机效果
需求
需求: 要做一个AI对话聊天的页面 就和正常的chatGPT、Deepseek一样,AI回复的问题需要有打字机效果,历史聊天记录不需要打字机效果仅限于最后一条回答实现打字机效果。
效果图
后面补上
录屏2025-11-28 16.03.26
实现方式
- 通过setInterval将数据一个字一个字的打印出来(仅限于AI回复的最后一次回答)
代码
<template>
<div :style="chatContainerHeight" class="chat-box">
<div v-for="(message, index) in messages" :key="index" class="message" :class="{'user-message': message.isUser, 'ai-message': !message.isUser}">
<div v-if="!message.isUser" class="avatar">
<img :src="message.isUser ? userAvatar : aiAvatar" alt="avatar" />
</div>
<div class="message-content">
<p v-if="message.deepThinking" style="color: #1ae46a;margin-bottom: -15px">深度思考</p>
<p>{{ message.text }}</p>
<p :style="customStyle(message)">{{ message.createdAt }}</p>
</div>
<div v-if="message.isUser" class="avatar">
<img :src="message.isUser ? userAvatar : aiAvatar" alt="avatar" />
</div>
</div>
</div>
<div class="chat-footer">
<div class="input-area">
<el-input ref="inputRef" v-model="newMessage" type="text" placeholder="请输入您想问的问题" class="inputDeep" maxlength="3000" show-word-limit @keydown.enter="sendMessage" />
<el-button :disabled="newMessage==='' || isDisabled" :type="newMessage===''? 'info' : 'primary'" @click="sendMessage">发送</el-button>
<div v-if="!messages.length" class="footer-tips"><span>限制体验次数为</span><span style="color: #ff6a00">{{count}}次</span></div>
<div v-else class="footer-tips"><span>体验模型将会消耗Tokens,费用以实际发生为准</span><span style="color: #ff6a00">{{count}}次</span></div>
</div>
</div>
<template>
<script setup>
const messages = ref([]);
// 聊天记录高度
const chatContainerHeight = computed(()=>{
if(!messages.value.length){
return {
height: `${window.innerHeight - 390 }px`,
}
}else{
return {
minHeight: "400px",
maxHeight: `${window.innerHeight - 390 }px`,
}
}
})
// 动态样式
const customStyle = (message) =>{
if(message.isUser){
return {
padding: "0 10px",
color: "#ccc",
textAlign: "right"
}
}else{
return {
padding: "0 10px",
color: "#ccc",
textAlign: "left"
}
}
}
function typeEffect(text, callback, doneCallback) {
let currentIndex = 0;
let resultText = '';
// 每隔 100 毫秒,更新一次字符
const interval = setInterval(() => {
resultText += text[currentIndex];
callback(resultText); // 回调函数,更新显示的文本
currentIndex++;
// 当所有字符都显示完毕时,清除定时器
if (currentIndex === text.length) {
clearInterval(interval);
if(doneCallback) doneCallback(); // 调用传入的doneCallback
}
}, 10); // 每 100 毫秒更新一个字符
}
const sendMessage = () => {
if (newMessage.value?.trim() === '') return;
if (isDisabled.value) return;
isDisabled.value = true;
params.messages[0].content = newMessage.value;
const userMessage = {
avatar: 'https://tse1-mm.cn.bing.net/th/id/OIP-C.Knh5i_ceDHm_cwzEcKFJ2gAAAA?w=208&h=208&c=7&r=0&o=7&pid=1.7&rm=3', // 你可以根据需要修改头像
text: newMessage.value,
isUser: true,
createdAt: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
messages.value.push(userMessage);
// 保存到本地存储
saveMessagesToStorage(messages.value);
// 接口是sse形式请求头Accept必须是text/event-stream
sseChat(params).then(res => {
const jsonResponse = res.replace(/^data:/, ''); // 去掉 'data:' 前缀
const result = JSON.parse(jsonResponse);
const tentParams = {
...result.choices[0].message,
isUser: false,
text: "",
createdAt: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
deepThinking: status.value // 深度思考
}
const textToDisplay = result.choices[0].message?.content;
messages.value.push(tentParams);
// 逐字显示
typeEffect(textToDisplay, (newText) => {
// 更新 messages.value 的 text 字段
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage && lastMessage.isUser === false) {
// 使用 Vue 的响应式方法更新 text,确保视图更新
lastMessage.text = newText; // 每次更新 text
// 强制重新赋值数组,以便 Vue 识别变更
messages.value = [...messages.value]; // 这里是通过赋新数组来强制视图更新
saveMessagesToStorage(messages.value);
}
}, ()=> {
// 后端返回的数据全部打自己效果完成之后执行这里
console.log("执行完毕")
isDisabled.value = false;
});
})
// 清空输入的值
newMessage.value = ''; // 发送后清空输入框
inputRef.value?.focus(); // 晴空内容后自动获取焦点
};
</script>
完整版代码
在这里插入代码片
<script setup>
import { ref } from 'vue';
import recommendModel from './recommendedModel.vue';
import {sseChat, searchQuery} from "~/apis/model-market/index";
const emit = defineEmits(["changeRecommendedRadio"]);
const route = useRoute();
const props = defineProps({});
const isDisabled = ref(false);
// 是否深度思考
const status = defineModel("status");
// 外层的下拉模型
const modelType = defineModel("modelType");
const inputRef = ref(null);
const count = ref(10);
const recommendedList = ref([]);
const params = reactive({
id: null,
model: "public/LLM-Research/Meta-Llama-3-8B-Instruct",
reasoning_effort: "",
messages: [{
role: "user",
content: "",
}]
});
watch(()=>status.value, newVal=>{
params.reasoning_effort = newVal ? "low": ""
console.log(route.query, "route");
})
// 推荐的模型 具体看选择哪个 默认为空对象
const modelVal = ref({});
const userAvatar = "https://tse1-mm.cn.bing.net/th/id/OIP-C.Knh5i_ceDHm_cwzEcKFJ2gAAAA?w=208&h=208&c=7&r=0&o=7&pid=1.7&rm=3"
const aiAvatar = "https://tse1-mm.cn.bing.net/th/id/OIP-C.bWLvtF_jhkcQdIyd8fH2JQAAAA?w=208&h=208&c=7&r=0&o=7&pid=1.7&rm=3"
const innerHeight = computed(()=>{
return {
height: `${window.innerHeight - 235 }px`
}
})
// 聊天记录高度
const chatContainerHeight = computed(()=>{
if(!messages.value.length){
return {
height: `${window.innerHeight - 390 }px`,
}
}else{
return {
minHeight: "400px",
maxHeight: `${window.innerHeight - 390 }px`,
}
}
})
// 加载本地存储的聊天记录
const loadMessagesFromStorage = () => {
const storedMessages = localStorage.getItem('chatMessages');
if (storedMessages) {
return JSON.parse(storedMessages);
}
return [];
};
const messages = ref(loadMessagesFromStorage());
const newMessage = ref('');
function typeEffect(text, callback, doneCallback) {
let currentIndex = 0;
let resultText = '';
// 每隔 100 毫秒,更新一次字符
const interval = setInterval(() => {
resultText += text[currentIndex];
callback(resultText); // 回调函数,更新显示的文本
currentIndex++;
// 当所有字符都显示完毕时,清除定时器
if (currentIndex === text.length) {
clearInterval(interval);
if(doneCallback) doneCallback(); // 调用传入的doneCallback
}
}, 10); // 每 100 毫秒更新一个字符
}
onMounted(async ()=>{
const params = {
pageNum: 1,
pageSize: 1000000000,
name: "",
typeIds: [],
providerId: [],
contextLength: [],
};
const res = await searchQuery(params)
const result = res.data.list.filter(item=>item?.isRecommend);
recommendedList.value = result.length > 3 ? result.slice(0,3) : result.slice(0, result.length - 1);
console.log(route.fullPath, "route")
})
const sendMessage = () => {
if (newMessage.value?.trim() === '') return;
if (isDisabled.value) return;
isDisabled.value = true;
params.messages[0].content = newMessage.value;
const userMessage = {
avatar: 'https://tse1-mm.cn.bing.net/th/id/OIP-C.Knh5i_ceDHm_cwzEcKFJ2gAAAA?w=208&h=208&c=7&r=0&o=7&pid=1.7&rm=3', // 你可以根据需要修改头像
text: newMessage.value,
isUser: true,
createdAt: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
messages.value.push(userMessage);
// 保存到本地存储
saveMessagesToStorage(messages.value);
sseChat(params).then(res => {
const jsonResponse = res.replace(/^data:/, ''); // 去掉 'data:' 前缀
const result = JSON.parse(jsonResponse);
const tentParams = {
...result.choices[0].message,
isUser: false,
text: "",
createdAt: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
deepThinking: status.value // 深度思考
}
const textToDisplay = result.choices[0].message?.content;
messages.value.push(tentParams);
// 逐字显示
typeEffect(textToDisplay, (newText) => {
// 更新 messages.value 的 text 字段
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage && lastMessage.isUser === false) {
// 使用 Vue 的响应式方法更新 text,确保视图更新
lastMessage.text = newText; // 每次更新 text
// 强制重新赋值数组,以便 Vue 识别变更
messages.value = [...messages.value]; // 这里是通过赋新数组来强制视图更新
saveMessagesToStorage(messages.value);
}
}, ()=> {
isDisabled.value = false;
});
})
// 清空输入的值
newMessage.value = ''; // 发送后清空输入框
inputRef.value?.focus(); // 晴空内容后自动获取焦点
};
// 保存聊天记录到本地存储
const saveMessagesToStorage = (messages) => {
localStorage.setItem(`${params.model}${params.id}`, JSON.stringify(messages));
};
// 清空聊天记录
const clearChatHistory = () => {
messages.value = [];
modelVal.value = {};
isDisabled.value = false;
saveMessagesToStorage(messages.value);
};
const handleChangeRadio = val => {
modelVal.value = val;
params.model = val.name
params.id = val.id
emit("changeRecommendedRadio", val);
}
// 动态样式
const customStyle = (message) =>{
if(message.isUser){
return {
padding: "0 10px",
color: "#ccc",
textAlign: "right"
}
}else{
return {
padding: "0 10px",
color: "#ccc",
textAlign: "left"
}
}
}
defineExpose({
clearChatHistory
})
</script>
<template>
<div :style="innerHeight" class="chat-container">
<div :style="chatContainerHeight" class="chat-box">
<div v-for="(message, index) in messages" :key="index" class="message" :class="{'user-message': message.isUser, 'ai-message': !message.isUser}">
<div v-if="!message.isUser" class="avatar">
<img :src="message.isUser ? userAvatar : aiAvatar" alt="avatar" />
</div>
<div class="message-content">
<p v-if="message.deepThinking" style="color: #1ae46a;margin-bottom: -15px">深度思考</p>
<p>{{ message.text }}</p>
<p :style="customStyle(message)">{{ message.createdAt }}</p>
</div>
<div v-if="message.isUser" class="avatar">
<img :src="message.isUser ? userAvatar : aiAvatar" alt="avatar" />
</div>
</div>
</div>
<div v-if="modelVal.name" class="tip-center">
<div v-if="!messages.length">已选择{{modelVal?.name}}开启模型体验吧</div>
</div>
<template v-if="route.fullPath !== '/workbench/workbench/instanceDetail'">
<div v-if="!messages.length" class="message-default">
<!-- <div v-if="!modelType" class="message-default">-->
<recommendModel :list="recommendedList" @change="handleChangeRadio" style="margin-bottom: 20px"/>
<p v-show="!modelVal.name" style="text-align: center;font-size: 16px"><strong>请先选择模型,在开始体验</strong></p>
</div>
</template>
<div class="chat-footer">
<div class="input-area">
<el-input ref="inputRef" v-model="newMessage" type="text" placeholder="请输入您想问的问题" class="inputDeep" maxlength="3000" show-word-limit @keydown.enter="sendMessage" />
<el-button :disabled="newMessage==='' || isDisabled" :type="newMessage===''? 'info' : 'primary'" @click="sendMessage">发送</el-button>
<div v-if="!messages.length" class="footer-tips"><span>限制体验次数为</span><span style="color: #ff6a00">{{count}}次</span></div>
<div v-else class="footer-tips"><span>体验模型将会消耗Tokens,费用以实际发生为准</span><span style="color: #ff6a00">{{count}}次</span></div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.tip-center{
position: absolute;
top: 45%;
left: 50%;
transform: translate(-50%,-50%);
}
.chat-container {
margin: 0 auto;
padding: 10px;
//background-color: #f5f5f5;
border-radius: 8px;
position: relative;
}
.chat-box {
//max-height: 500px;
overflow-y: auto;
margin-bottom: 10px;
}
.message {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
width: 100%;
}
.avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.user-message .message-content {
background-color: #f5f5f5;
border-radius: 10px 10px 0 10px;
margin-left: auto; /* 用户消息靠右 */
width: auto; /* 内容宽度自适应 */
max-width: 85%; /* 设置最大宽度 */
word-wrap: break-word;
}
.ai-message .message-content {
background-color: #f5f5f5;
border-radius: 10px 10px 10px 0;
margin-right: auto; /* AI消息靠左 */
width: auto; /* 内容宽度自适应 */
max-width: 85%; /* 设置最大宽度 */
word-wrap: break-word;
}
.message-content {
//background-color: #fff;
background-color: #f1f2f4;
/* padding: 10px; */
border-radius: 10px;
max-width: 100%;
word-wrap: break-word;
/* min-height: 40px; */
/* line-height: 25px; */
p{
padding: 10px;
margin: 0;
}
}
.chat-footer{
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
}
.footer-tips{
position: absolute;
top: -30px;
left: 0;
}
.input-area {
width: 80%;
padding: 20px 10px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
//background-color: pink;
position: relative;
background-color: #fff;
border: solid 1px #d7d7d7;
border-radius: 8px;
margin: 0 auto;
}
.inputDeep {
// text
:deep(.el-input__wrapper) {
box-shadow: 0 0 0 0 var(--el-input-border-color, var(--el-border-color)) inset;
cursor: default;
.el-input__inner {
cursor: default !important;
}
}
// textarea
:deep(.el-textarea__inner) {
box-shadow: 0 0 0 0 var(--el-input-border-color, var(--el-border-color)) inset;
resize: none;
cursor: default;
}
}
.input-area input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 20px;
}
.input-area button {
padding: 10px 15px;
//background-color: #007bff;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
}
.input-area button:hover {
//background-color: #0056b3;
}
// 初始模型样式
.message-default{
position: absolute;
//bottom: 100px;
bottom: 10px;
left: 50%;
transform: translate(-50%,-50%);
}
</style>
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)