Browse Source

实现流式输出打字效果

zhangwl 23 hours ago
parent
commit
9c8db587f0
2 changed files with 132 additions and 126 deletions
  1. 107 123
      chat-ai-ui-main/src/stores/chat.ts
  2. 25 3
      chat-ai-ui-main/src/views/ChatView.vue

+ 107 - 123
chat-ai-ui-main/src/stores/chat.ts

@@ -8,173 +8,157 @@ interface FormattedMessage {
   content: string;
 }
 
+interface Message {
+  role: string;
+  content: string;
+  displayContent: string;
+  isStreaming: boolean;
+}
+
 export const useChatStore = defineStore('chat', () => {
-  const messages = ref<{ role: string; content: string }[]>([]);
+  const messages = ref<Message[]>([]);
   const isLoading = ref(false);
 
   const userStore = useUserStore();
 
-  // 获取历史消息接口调用
   const loadChatHistory = async () => {
     if (!userStore.userId) return;
-
     try {
       const { data } = await axios.get(
         `${import.meta.env.VITE_API_URL}/chat/history`,
-        {
-          headers: {
-          'Authorization': `Bearer ${userStore.userId}`, // 添加认证头
-          'Content-Type': 'application/json'
-          }
-        }
+        { headers: { 'Authorization': `Bearer ${userStore.userId}` } }
       );
-
-      messages.value = data.filter((msg: FormattedMessage) => msg.content);
+      messages.value = data
+        .filter((msg: FormattedMessage) => msg.content)
+        .map((msg: FormattedMessage) => ({
+          role: msg.role,
+          content: msg.content,
+          displayContent: msg.content,
+          isStreaming: false,
+        }));
     } catch (error) {
       console.error('Error loading chat history: ', error);
     }
   };
 
-  // 对话接口调用
   const sendMessage = async (message: string, model: string, temperature: number, max_tokens: number, stream: boolean) => {
-    console.log(123);
     if (!message.trim() || !userStore.userId) return;
 
-    messages.value.push({ role: 'user', content: message });
-
+    messages.value.push({ role: 'user', content: message, displayContent: message, isStreaming: false });
     isLoading.value = true;
 
     try {
       if (stream) {
-      // 流式处理
-      await handleStreamResponse(model, temperature, max_tokens);
-    } else {
-      // 非流式处理(保持原有逻辑)
-      const { data } = await axios.post(
-        `${import.meta.env.VITE_API_URL}/chat/chat`,
-        {
-          // 请求体数据
-          messages: messages.value,
-          model: model,
-          temperature: temperature,
-          max_tokens: max_tokens,
-          stream: false,
-        },
-        {
-          // axios 配置选项
-          headers: {
-            'Authorization': `Bearer ${userStore.userId}`, // 认证头
-            'Content-Type': 'application/json', // 可选,axios通常会自动设置
-          }
-        }
-      );
-      messages.value.push({ role: data.message.role, content: data.message.content });
-    }
+        await handleStreamResponse(model, temperature, max_tokens);
+      } else {
+        const { data } = await axios.post(
+          `${import.meta.env.VITE_API_URL}/chat/chat`,
+          { messages: messages.value.map(m => ({ role: m.role, content: m.content })), model, temperature, max_tokens, stream: false },
+          { headers: { 'Authorization': `Bearer ${userStore.userId}`, 'Content-Type': 'application/json' } }
+        );
+        messages.value.push({
+          role: data.message.role,
+          content: data.message.content,
+          displayContent: data.message.content,
+          isStreaming: false,
+        });
+      }
     } catch (error) {
       console.error('Error sending message: ', error);
-      messages.value.push({
-        role: 'ai',
-        content: 'Error: unable to process request',
-      });
+      messages.value.push({ role: 'assistant', content: 'Error: unable to process request', displayContent: 'Error: unable to process request', isStreaming: false });
     } finally {
       isLoading.value = false;
     }
   };
 
+  const handleStreamResponse = async (model: string, temperature: number, max_tokens: number) => {
+    // 先快照当前对话历史(不含即将添加的占位),用于发送请求
+    const historySnapshot = messages.value.map(m => ({ role: m.role, content: m.content }));
+
+    const aiMessageIndex = messages.value.length;
+    messages.value.push({ role: 'assistant', content: '', displayContent: '', isStreaming: true });
+
+    try {
+      const response = await fetch(`${import.meta.env.VITE_API_URL}/chat/chat`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${userStore.userId}`,
+        },
+        body: JSON.stringify({ messages: historySnapshot, model, temperature, max_tokens, stream: true }),
+      });
+
+      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+      if (!response.body) throw new Error('ReadableStream not supported');
+
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let buffer = '';
+      // 待显示字符队列
+      let charQueue = '';
+      let isTyping = false;
+
+      // 逐字打印函数,每个字符间隔 20ms
+      const typeNextChar = () => {
+        if (charQueue.length === 0) {
+          isTyping = false;
+          return;
+        }
+        messages.value[aiMessageIndex].displayContent += charQueue[0];
+        charQueue = charQueue.slice(1);
+        setTimeout(typeNextChar, 20);
+      };
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        buffer += decoder.decode(value, { stream: true });
+        const lines = buffer.split('\n');
+        buffer = lines.pop() || '';
+
+        for (const line of lines) {
+          const trimmedLine = line.trim();
+          if (!trimmedLine.startsWith('data: ')) continue;
 
-/**
- * 处理流式响应
- * @param model - AI模型名称
- * @param temperature - 温度参数,控制回复的随机性
- * @param max_tokens - 最大token数量限制
- */
-const handleStreamResponse = async (model: string, temperature: number, max_tokens: number) => {
-  // 获取即将添加的AI消息在数组中的索引位置
-  const aiMessageIndex = messages.value.length;
-  // 预先添加一个空的AI消息占位,用于后续实时更新内容
-  messages.value.push({ role: 'assistant', content: '' });
-  try {
-    // 发起流式请求到后端API
-    const response = await fetch(`${import.meta.env.VITE_API_URL}/chat/chat`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        'Authorization': `Bearer ${userStore.userId}`, // 认证头
-      },
-      body: JSON.stringify({
-        messages: messages.value,           // 完整的对话历史
-        model: model,                       // AI模型
-        temperature: temperature,           // 温度参数
-        max_tokens: max_tokens,            // token限制
-        stream: true                       // 开启流式响应
-      }
-    )
-    });
-    // 检查HTTP响应状态
-    if (!response.ok) {
-      throw new Error(`HTTP error! status: ${response.status}`);
-    }
-     // 检查浏览器是否支持ReadableStream
-    if (!response.body) {
-      throw new Error('ReadableStream not supported');
-    }
-     // 获取流式数据读取器
-    const reader = response.body.getReader();
-    // 创建文本解码器,用于将字节流转换为文本
-    const decoder = new TextDecoder();
-    // 缓冲区,用于处理可能被分割的数据行
-    let buffer = '';
-    // 持续读取流式数据
-    while (true) {
-       // 读取数据块
-      const { done, value } = await reader.read();
-      // 如果流结束,跳出循环
-      if (done) break;
-       // 将字节数据解码为文本并添加到缓冲区
-      buffer += decoder.decode(value, { stream: true });
-      // 按换行符分割数据,处理SSE格式的数据行
-      const lines = buffer.split('\n');
-      // 保留最后一行(可能是不完整的),其余行进行处理
-      buffer = lines.pop() || '';
-
-      // 逐行处理SSE数据
-      for (const line of lines) {
-        const trimmedLine = line.trim();
-         // 检查是否是SSE数据行(以"data: "开头)
-        if (trimmedLine.startsWith('data: ')) {
-          // 提取JSON数据部分,移除"data: "前缀
           const jsonStr = trimmedLine.slice(6);
-          
-          // 跳过空数据行或结束标记
           if (jsonStr.trim() === '' || jsonStr.trim() === '[DONE]') continue;
 
           try {
-             // 解析JSON数据
             const data = JSON.parse(jsonStr);
-            // 如果包含内容数据,实时更新AI消息
             if (data.content) {
-              // 将新内容追加到AI消息中,实现打字机效果
               messages.value[aiMessageIndex].content += data.content;
+              charQueue += data.content;
+              if (!isTyping) {
+                isTyping = true;
+                typeNextChar();
+              }
             }
-            // 如果收到结束信号,跳出数据处理循环
-            if (data.finished) {
-              break;
-            }
+            if (data.finished) break;
           } catch (e) {
             console.warn('Failed to parse JSON:', jsonStr, e);
           }
         }
       }
+
+      reader.releaseLock();
+
+      // 等待打字队列清空后再关闭光标
+      const waitTypingDone = () => {
+        if (charQueue.length > 0) {
+          setTimeout(waitTypingDone, 50);
+        } else {
+          messages.value[aiMessageIndex].isStreaming = false;
+        }
+      };
+      waitTypingDone();
+
+    } catch (error) {
+      console.error('Stream error:', error);
+      messages.value.splice(aiMessageIndex, 1);
+      throw error;
     }
-    // 释放读取器资源
-    reader.releaseLock();
-    
-  } catch (error) {
-    console.error('Stream error:', error);
-    messages.value.splice(aiMessageIndex, 1);
-    throw error;
-  }
-};
+  };
 
   return { messages, isLoading, loadChatHistory, sendMessage };
-});
+});

+ 25 - 3
chat-ai-ui-main/src/views/ChatView.vue

@@ -105,14 +105,17 @@ watch(
 
             <!-- 消息内容 -->
             <div
-              v-html="formatMessage(msg.content, msg.role)"
-              class="max-w-2xl px-4 py-3 rounded-lg shadow-sm prose prose-invert max-w-none"
+              class="max-w-2xl px-4 py-3 rounded-lg shadow-sm prose prose-invert max-w-none relative"
               :class="
                 msg.role === 'user'
                   ? 'bg-blue-600 text-white rounded-br-sm prose-headings:text-white prose-p:text-white prose-strong:text-white prose-em:text-white'
                   : 'bg-gray-700 text-white rounded-bl-sm prose-headings:text-gray-100 prose-p:text-gray-100 prose-strong:text-gray-100 prose-em:text-gray-100 prose-code:text-blue-300 prose-code:bg-gray-800 prose-pre:bg-gray-800 prose-blockquote:border-blue-500'
               "
-            ></div>
+            >
+              <div v-html="formatMessage(msg.displayContent || msg.content, msg.role)"></div>
+              <!-- 打字光标 -->
+              <span v-if="msg.isStreaming" class="typing-cursor"></span>
+            </div>
             <!-- 用户 -->
             <div v-if="msg.role === 'user'" class="flex-shrink-0 ml-3">
               <div class="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-sm font-semibold">
@@ -327,4 +330,23 @@ watch(
 :deep(.hljs-type) {
   color: #ef4444;
 }
+
+/* 打字光标效果 */
+.typing-cursor {
+  display: inline-block;
+  width: 2px;
+  height: 1em;
+  background-color: currentColor;
+  margin-left: 2px;
+  animation: blink 1s infinite;
+}
+
+@keyframes blink {
+  0%, 49% {
+    opacity: 1;
+  }
+  50%, 100% {
+    opacity: 0;
+  }
+}
 </style>