背景基于Vue3+ts+vite 框架下 ,对话模式,支持流式输出

  1.  由于deepseek  调用接口需要支付token费用,阿里云有免费的token可使用

          Deepseek API文档 

        

        所以需要先注册一个阿里云领取免费的

  第一步注册 阿里云-体验deepseek 

第二步:申请key

第三步安装依赖:npm install markdown-it highlight.js lodash

第四步如果不嫌弃麻烦 自己画页面,嫌麻烦 copy 如下代码,直接运行 

<template>
    <div class="advanced-chat-container">
     <!-- 带动画效果的对话区域 -->
     <TransitionGroup 
       name="message-fade"
       tag="div" 
       class="messages-wrapper"
       @scroll.passive="handleScroll"
     >
       <div 
         v-for="(msg, index) in messages"
         :key="msg.id"
         :class="['message-item', msg.role]"
       >
         <!-- 用户消息 -->
         <div v-if="msg.role === 'user'" class="message-bubble user">
           {{ msg.content }}
           <span class="timestamp">{{ formatTime(msg.createdAt) }}</span>
         </div>
 
         <!-- AI消息 -->
         <div v-else  class="message-ai">
          <!-- <img :src="logo" class="sidebar-logo" /> -->
         <div class="message-bubble ai">
           <div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
           <div v-if="msg.status === 'streaming'" class="streaming-cursor"></div>
           <div class="status-footer">
             <span class="timestamp">{{ formatTime(msg.updatedAt) }}</span>
             <span v-if="msg.status === 'error'" class="retry-btn" @click="retryMessage(msg)">
               ⟳ 重试
             </span>
           </div>
         </div>
         </div>
       </div>
 
       <!-- 加载指示器 -->
       <div v-if="loading" class="loading-indicator">
         <div class="spinner"></div>
         <span>菲草正在生成回答...</span>
       </div>
     </TransitionGroup>
 
     <!-- 增强输入区域 -->
     <div class="enhanced-input-area">
       <div class="input-wrapper">
         <textarea
           ref="inputEl"
           v-model="inputText"
           :disabled="loading"
           @keydown.enter.exact.prevent="sendMessage"
           @keydown.shift.enter.exact="handleShiftEnter"
           placeholder="输入消息(Enter发送,Shift+Enter换行)"
           rows="1"
           autocomplete="off"
           spellcheck="false"
         ></textarea>
         <button 
           class="send-btn"
           :class="{ loading }"
           @click="sendMessage"
           :disabled="!canSend || loading"
         >
           <span v-if="!loading">发送</span>
           <div v-else class="sending-spinner">
             <span @click.stop="abortRequest" :disabled="!isStreaming">停止</span>
           </div>
         </button>
       </div>
       <div class="input-footer">
         <!-- <span class="hint-text">支持Markdown语法</span> -->
         <span class="char-count">{{ inputText.length }}/1000</span>
       </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">import { ref, reactive, computed, nextTick, onMounted } from 'vue';
 import axios from 'axios';
 import OpenAI from "openai";
 import MarkdownIt from 'markdown-it';
 import logo from '@/assets/logo/logo.png';
 import hljs from 'highlight.js';
 import 'highlight.js/styles/atom-one-dark.css';
 import 'highlight.js/lib/common';
 
 // Markdown配置(带代码高亮)
 const md = new MarkdownIt({
   html: true,
   linkify: true,
   highlight: (str, lang) => {
     if (lang && hljs.getLanguage(lang)) {
       try {
         return `<pre class="hljs"><code>${hljs.highlight(str, { 
           language: lang, 
           ignoreIllegals: true 
         }).value}</code></pre>`
       } catch (__) {}
     }
     return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
   }
 })
 
 // 响应式数据
 const messages = reactive([])
 const inputText = ref('')
 const loading = ref(false)
 const inputEl = ref(null)
 const autoScroll = ref(true)
 const isStreaming = ref(false)
 const abortController = ref<AbortController | null>(null)
 // 保存消息容器的引用
 const messagesContainer = ref(null);
 
 // 计算属性
 const canSend = computed(() => {
   return inputText.value.trim().length > 0 && 
     !loading.value &&
     inputText.value.length <= 1000
 })
 
 // 格式化时间
 const formatTime = (date) => {
   return new Date(date).toLocaleTimeString('zh-CN', {
     hour: '2-digit',
     minute: '2-digit'
   })
 }
 
 // Markdown渲染方法
 const renderMarkdown = (content) => {
   return md.render(content)
 }
 
 const processStream = async (reader) => {
   const decoder = new TextDecoder('utf-8');
   let buffer = '';
   const currentMsg = messages[messages.length - 1]; // 当前消息对象
 
   try {
     while (true) {
       const { done, value } = await reader.read();
       if (done) {
         currentMsg.status = 'completed';
         break;
       }
 
       // 将二进制数据解码为字符串
       buffer += decoder.decode(value, { stream: true });
 
       // 处理可能的多个数据块
       while (buffer.includes('\n')) {
         const lineEnd = buffer.indexOf('\n');
         const line = buffer.slice(0, lineEnd).trim(); // 去除空白字符
         buffer = buffer.slice(lineEnd + 1);
 
         if (line.startsWith('data: ')) {
           const payload = line.replace('data: ', '');
 
           // 检查是否结束
           if (payload === '[DONE]') {
             currentMsg.status = 'completed';
             break;
           }
 
           try {
             const parsed = JSON.parse(payload);
 
             // 提取增量内容
             if (parsed.choices && parsed.choices[0].delta) {
               const delta = parsed.choices[0].delta;
 
               // 更新消息内容
               if (delta.content) {
                 currentMsg.content += delta.content;
               }
               if (delta.reasoning_content) {
                 currentMsg.content += delta.reasoning_content;
               }
 
               // 更新状态和时间戳
               currentMsg.updatedAt = Date.now();
 
               // 检查是否结束
               if (parsed.choices[0].finish_reason === 'stop') {
                 currentMsg.status = 'completed';
                 break;
               }
             }
           } catch (error) {
             console.error('JSON 解析错误:', error);
             currentMsg.status = 'error';
             currentMsg.content += '\n(数据解析错误)';
           }
         }
       }
 
       // 更新 UI
       await nextTick();
       if (autoScroll.value) {
         scrollToBottom();
       }
     }
   } catch (error) {
     console.error('流处理错误:', error);
     currentMsg.status = 'error';
     currentMsg.content += '\n(好的,已停下)';
   } finally {
     loading.value = false; // 结束加载状态
     isStreaming.value = false; // 结束流式传输状态
   }
 };
 
 // 滚动控制
 const scrollToBottom = () => {
    if (messagesContainer.value) {
     messagesContainer.value.scrollTo({
       top: messagesContainer.value.scrollHeight,
       behavior: 'smooth'
     });
   }
 };
 
 // 自动滚动条件判断
 const handleScroll = (e) => {
   const { scrollTop, scrollHeight, clientHeight } = e.target
   autoScroll.value = scrollHeight - (scrollTop + clientHeight) < 50
 }
 
 // 发送消息
 const sendMessage = async () => {
   if (!canSend.value) return
   
   const userMessage = {
     id: Date.now(),
     role: 'user',
     content: inputText.value.trim(),
     createdAt: Date.now()
   }
   
   const aiMessage = {
     id: Date.now() + 1,
     role: 'assistant',
     content: '',
     status: 'streaming',
     createdAt: Date.now(),
     updatedAt: Date.now()
   }
   
   messages.push(userMessage, aiMessage)
   inputText.value = ''
   loading.value = true
   autoScroll.value = true
   isStreaming.value = true
 
   // 创建 AbortController 实例
   abortController.value = new AbortController()
 
   try {
     const response = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
         'Authorization': `Bearer ${import.meta.env.VITE_APP_ALIYUNAI_TESTKEY}`
       },
       body: JSON.stringify({
         model: "deepseek-r1",
         messages: messages.slice(0, -1).map(msg => ({
           role: msg.role,
           content: msg.content
         })),
         max_tokens: 4096,
         temperature: 0.1,
         stream: true,
       }),
       signal: abortController.value.signal // 传递 AbortSignal
     })
 
     if (!response.ok) throw new Error(`HTTP错误 ${response.status}`)
     await processStream(response.body.getReader())
   } catch (error) {
     if (error.name === 'AbortError') {
       console.log('请求已中止')
       aiMessage.status = 'aborted'
       aiMessage.content += '\n 好的,已停下。'
     } else {
       console.error('请求失败:', error)
       aiMessage.status = 'error'
       aiMessage.content = '请求失败: ' + error.message
     }
   } finally {
     loading.value = false
     isStreaming.value = false
     abortController.value = null
   }
 }
 
 // 中断请求
 const abortRequest = () => {
   if (abortController.value) {
     abortController.value.abort()
     isStreaming.value = false
   }
 }
 
 // 其他交互逻辑
 const handleShiftEnter = () => {
   inputText.value += '\n'
   autoResizeTextarea()
 }
 
 const autoResizeTextarea = () => {
   nextTick(() => {
     const textarea = inputEl.value;
     textarea.style.height = 'auto';
     textarea.style.height = `${textarea.scrollHeight}px`;
   });
 };
 
 const retryMessage = (msg) => {
   const index = messages.indexOf(msg)
   if (index !== -1) {
     messages.splice(index, 1)
     inputText.value = messages[index - 1].content
     sendMessage()
   }
 }
 
 // 生命周期钩子
 onMounted(() => {
   autoResizeTextarea()
   scrollToBottom()
 })
 </script>
 
 
 
 <style lang="scss" scoped>
 .advanced-chat-container {
   max-width: 1200px;
   margin: 0 auto;
   height: 100vh;
   display: flex;
   flex-direction: column;
   background: #f8f9fa;
 }
 
 .messages-wrapper {
   flex: 1;
   overflow-y: auto;
   padding: 20px;
   background: linear-gradient(to bottom, #ffffff, #f5f7fb);
   
   &::-webkit-scrollbar {
     width: 6px;
   }
   
   &::-webkit-scrollbar-track {
     background: #f1f1f1;
   }
   
   &::-webkit-scrollbar-thumb {
     background: #888;
     border-radius: 4px;
   }
 }
 
 .message-item {
   color: #060607;
   font-size: 14px;
   margin: 12px 0;
   transition: all 0.3s ease;
   
   &.message-fade-enter-from {
     opacity: 0;
     transform: translateY(20px);
   }
 }
 .message-ai{
   display: flex;
   align-items: flex-start;
   margin-left: 48px;
   position: relative;
   transition: all 0.3s ease;
   &:before {
     content: '';
     position: absolute;
     left: -48px;
     top: 0;
     width: 28px;
     height: 28px;
     border-radius: 50%;
     background: #f5f5f5;
     transition: all 0.3s ease;
     background-image: url(@/assets/logo/logo.png); /* 替换为你的图片URL */
     background-size: cover; /* 确保图片覆盖整个圆形区域 */
     background-position: center; /* 图片居中显示 */
   }
 }
 .message-bubble {
   max-width: 75%;
   padding: 16px;
   border-radius: 12px;
   position: relative;
   box-shadow: 0 2px 8px rgba(0,0,0,0.1);
   
   &.user {
     background: #007bff;
     color: white;
     margin-left: auto;
     
     .timestamp {
       color: rgba(255,255,255,0.7);
     }
   }
   
   &.ai {
     background: white;
     border: 1px solid #e0e0e0;
     
     .markdown-content {
       :deep(pre) {
         background: #1e1e1e;
         padding: 12px;
         border-radius: 6px;
         overflow-x: auto;
       }
       
       :deep(code) {
         font-family: 'Fira Code', monospace;
         font-size: 0.9em;
       }
     }
     
     .streaming-cursor {
       display: inline-block;
       width: 8px;
       height: 1em;
       background: #666;
       margin-left: 4px;
       animation: blink 1s infinite;
     }
   }
 }
 
 .status-footer {
   display: flex;
   align-items: center;
   justify-content: space-between;
   margin-top: 8px;
   font-size: 0.8em;
   color: #666;
   
   .retry-btn {
     color: #007bff;
     cursor: pointer;
     padding: 4px 8px;
     border-radius: 4px;
     
     &:hover {
       background: rgba(0,123,255,0.1);
     }
   }
 }
 
 @keyframes blink {
   50% { opacity: 0 }
 }
 
 .enhanced-input-area {
   background: white;
   padding: 16px;
   box-shadow: 0 -4px 12px rgba(0,0,0,0.05);
   
   .input-wrapper {
     display: flex;
     gap: 12px;
     align-items: flex-end;
     
     textarea {
       flex: 1;
       min-height: 48px;
       max-height: 200px;
       padding: 12px;
       border: 1px solid #ddd;
       border-radius: 8px;
       resize: none;
       font-family: inherit;
       line-height: 1.5;
       
       &:focus {
         border-color: #007bff;
         outline: none;
         box-shadow: 0 0 0 2px rgba(0,123,255,0.2);
       }
     }
     
     .send-btn {
       height: 48px;
       width: 80px;
       border: none;
       border-radius: 8px;
       background: #007bff;
       color: white;
       cursor: pointer;
       transition: all 0.2s;
       
       &:disabled {
         background: #ccc;
         cursor: not-allowed;
       }
       
       &.loading {
         background: #007bffaa;
       }
     }
 
     .abort-btn {
       height: 48px;
       width: 80px;
       border: none;
       border-radius: 8px;
       background: #ff4d4f;
       color: white;
       cursor: pointer;
       transition: all 0.2s;
       
       &:disabled {
         background: #ccc;
         cursor: not-allowed;
       }
     }
   }
   
   .input-footer {
     display: flex;
     justify-content: space-between;
     margin-top: 8px;
     font-size: 0.9em;
     color: #666;
     
     .char-count {
       margin-left: auto;
       color: #999;
     }
   }
 } 
 
 .loading-indicator {
   display: flex;
   align-items: center;
   font-size: 12px;
   gap: 8px;
   color: #666;
   padding: 12px;
   margin-left: 60px;
   padding-top:0px ;
   .spinner {
     width: 12px;
     height: 12px;
     border: 2px solid #ddd;
     border-top-color: #007bff;
     border-radius: 50%;
     animation: spin 1s linear infinite;
   }
 }
 
 @keyframes spin {
   to { transform: rotate(360deg) }
 }
 </style>
 

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐