AI 对话实现打字机效果
需求
需求: 要做一个AI对话聊天的页面 就和正常的chatGPT、Deepseek一样,AI回复的问题需要有打字机效果,历史聊天记录不需要打字机效果仅限于最后一条回答实现打字机效果。
效果图
后面补上
实现方式
- 通过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>
 
                   
                   
                   
                   
                             
       
           
                 
                 
                 
                 
                 
                
               
                 
                 
                 
                 
                
               
                 
                 扫一扫
扫一扫
                     
              
             
                   307
					307
					
 被折叠的  条评论
		 为什么被折叠?
被折叠的  条评论
		 为什么被折叠?
		 
		  到【灌水乐园】发言
到【灌水乐园】发言                                
		 
		 
    
   
    
   
             
            


 
            