当前位置: 首页 > news >正文

AI 对话实现打字机效果 Vue3 setup

AI 对话实现打字机效果

需求

需求: 要做一个AI对话聊天的页面 就和正常的chatGPT、Deepseek一样,AI回复的问题需要有打字机效果,历史聊天记录不需要打字机效果仅限于最后一条回答实现打字机效果。

效果图

后面补上

实现方式

  1. 通过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-streamsseChat(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.nameparams.id = val.idemit("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>
http://www.lryc.cn/news/584344.html

相关文章:

  • SEO算法更新应对:5大实战案例与优化策略
  • 力扣刷题记录(c++)06
  • The 2023 ICPC Asia Hangzhou Regional Contest(G. Snake Move(最短路))
  • Map容器用map优化程序
  • 《一起出发,“春”不“晚”》特别行动踏梦武当,探寻新春奇境
  • 动态规划疑惑总结
  • 爬虫-正则使用
  • 8.2.3希尔排序
  • 【Bluedroid】蓝牙协议栈控制器能力解析与核心功能配置机制(decode_controller_support)
  • 【Nginx】Nginx 安装与 Sticky 模块配置
  • Android 13----在framworks层映射一个物理按键
  • FlashAttention 快速安装指南(避免长时间编译)
  • GoView 低代码数据可视化
  • JAVA JVM对象的实现
  • 机器学习与光子学的融合正重塑光学器件设计范式
  • 统计文件内容:统计一个文本文件中字符、单词、行数。
  • C#中异步任务取消:CancellationToken
  • HOOK专题
  • Linux流量分析:tcpdump wireshark
  • EchoSight-Pro发布说明
  • 【网络】Linux 内核优化实战 - net.ipv4.tcp_fin_timeout
  • Android Coil 3 data加载图的Bitmap或ByteArray数据类型,Kotlin
  • 设计总监年中复盘:用Adobe XD内容识别布局,告别“手动调距”
  • 大模型在膀胱癌诊疗全流程预测及应用研究报告
  • HarmonyOS AI辅助编程工具(CodeGenie)UI生成
  • RabbitMQ 高级特性之消息分发
  • web 系统对接飞书三方登录完整步骤实战使用示例
  • 网络安全(初级)(1)
  • AI+低代码双引擎驱动:重构智能业务系统的产品逻辑
  • Fiddler中文版全面评测:功能亮点、使用场景与中文网资源整合指南