Browse Source

流式添加思考过程

zhangwl 1 month ago
parent
commit
ed15b1035a
3 changed files with 159 additions and 56 deletions
  1. 1 2
      chat-ai-ui-main/README.md
  2. 77 34
      chat-ai-ui-main/src/stores/chat.ts
  3. 81 20
      chat-ai-ui-main/src/views/ChatView.vue

+ 1 - 2
chat-ai-ui-main/README.md

@@ -4,7 +4,6 @@
 
 <img src="./src/assets/screen.png" />
 
-该应用的 Express 后端 API 可在[此处](https://github.com/bradtraversy/chat-ai-api)找到。
 
 ## 安装说明
 
@@ -16,4 +15,4 @@
 VITE_API_URL=http://localhost:8000
 ```
 
-4. Run the server with `npm run dev` and open on `http://localhost:5173/`
+4. Run the server with `npm run dev` and open on `http://localhost:5173/`

+ 77 - 34
chat-ai-ui-main/src/stores/chat.ts

@@ -12,9 +12,23 @@ interface Message {
   role: string;
   content: string;
   displayContent: string;
+  thinkingContent: string;
+  thinkingDisplayContent: string;
+  isThinkingCollapsed: boolean;
+  searchingItems: string[];
+  phase: 'thinking' | 'searching' | 'answer' | 'done';
   isStreaming: boolean;
 }
 
+const blankMeta = () => ({
+  thinkingContent: '',
+  thinkingDisplayContent: '',
+  isThinkingCollapsed: true,
+  searchingItems: [] as string[],
+  phase: 'done' as const,
+  isStreaming: false,
+});
+
 export const useChatStore = defineStore('chat', () => {
   const messages = ref<Message[]>([]);
   const isLoading = ref(false);
@@ -34,17 +48,17 @@ export const useChatStore = defineStore('chat', () => {
           role: msg.role,
           content: msg.content,
           displayContent: msg.content,
-          isStreaming: false,
+          ...blankMeta(),
         }));
     } catch (error) {
       console.error('Error loading chat history: ', error);
     }
   };
 
-  const sendMessage = async (message: string,  stream: boolean) => {
+  const sendMessage = async (message: string, stream: boolean) => {
     if (!message.trim() || !userStore.userId) return;
 
-    messages.value.push({ role: 'user', content: message, displayContent: message, isStreaming: false });
+    messages.value.push({ role: 'user', content: message, displayContent: message, ...blankMeta() });
     isLoading.value = true;
 
     try {
@@ -53,32 +67,43 @@ export const useChatStore = defineStore('chat', () => {
       } else {
         const { data } = await axios.post(
           `${import.meta.env.VITE_API_URL}/chat/chat`,
-          { messages: [{ role: 'user', content: message}],  stream: false },
-          // { messages: messages.value.map(m => ({ role: m.role, content: m.content })), model, temperature, max_tokens, stream: false },
+          { messages: [{ role: 'user', content: message }], 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,
+          ...blankMeta(),
         });
       }
     } catch (error) {
       console.error('Error sending message: ', error);
-      messages.value.push({ role: 'assistant', content: 'Error: unable to process request', displayContent: 'Error: unable to process request', isStreaming: false });
+      messages.value.push({
+        role: 'assistant', content: 'Error: unable to process request',
+        displayContent: 'Error: unable to process request',
+        ...blankMeta(),
+      });
     } finally {
       isLoading.value = false;
     }
   };
 
   const handleStreamResponse = async () => {
-    // 先快照当前对话历史(不含即将添加的占位),用于发送请求
-    const historySnapshot = [{role:'user',content: messages.value[messages.value.length - 1].content}];
-    // const historySnapshot = messages.value.map(m => ({ role: m.role, content: m.content }));
+    const historySnapshot = [{ role: 'user', content: messages.value[messages.value.length - 1].content }];
 
     const aiMessageIndex = messages.value.length;
-    messages.value.push({ role: 'assistant', content: '', displayContent: '', isStreaming: true });
+    messages.value.push({
+      role: 'assistant',
+      content: '',
+      displayContent: '',
+      thinkingContent: '',
+      thinkingDisplayContent: '',
+      isThinkingCollapsed: false,
+      searchingItems: [],
+      phase: 'thinking',
+      isStreaming: true,
+    });
 
     try {
       const response = await fetch(`${import.meta.env.VITE_API_URL}/chat/chat`, {
@@ -88,7 +113,6 @@ export const useChatStore = defineStore('chat', () => {
           'Authorization': `Bearer ${userStore.userId}`,
         },
         body: JSON.stringify({ messages: historySnapshot, stream: true }),
-        // body: JSON.stringify({ messages: historySnapshot, model, temperature, max_tokens, stream: true }),
       });
 
       if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
@@ -97,19 +121,24 @@ export const useChatStore = defineStore('chat', () => {
       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);
+
+      let answerQueue = '';
+      let isAnswerTyping = false;
+      let thinkingQueue = '';
+      let isThinkingTyping = false;
+
+      const typeNextAnswerChar = () => {
+        if (answerQueue.length === 0) { isAnswerTyping = false; return; }
+        messages.value[aiMessageIndex].displayContent += answerQueue[0];
+        answerQueue = answerQueue.slice(1);
+        setTimeout(typeNextAnswerChar, 20);
+      };
+
+      const typeNextThinkingChar = () => {
+        if (thinkingQueue.length === 0) { isThinkingTyping = false; return; }
+        messages.value[aiMessageIndex].thinkingDisplayContent += thinkingQueue[0];
+        thinkingQueue = thinkingQueue.slice(1);
+        setTimeout(typeNextThinkingChar, 10);
       };
 
       while (true) {
@@ -129,14 +158,28 @@ export const useChatStore = defineStore('chat', () => {
 
           try {
             const data = JSON.parse(jsonStr);
-            if (data.content) {
+
+            if (data.type === 'thinking' && data.content) {
+              messages.value[aiMessageIndex].thinkingContent += data.content;
+              thinkingQueue += data.content;
+              if (!isThinkingTyping) { isThinkingTyping = true; typeNextThinkingChar(); }
+
+            } else if (data.type === 'searching' && data.content) {
+              // 立即刷新思考打字队列,折叠思考块,切换到搜索阶段
+              messages.value[aiMessageIndex].thinkingDisplayContent = messages.value[aiMessageIndex].thinkingContent;
+              thinkingQueue = '';
+              isThinkingTyping = false;
+              messages.value[aiMessageIndex].isThinkingCollapsed = true;
+              messages.value[aiMessageIndex].phase = 'searching';
+              messages.value[aiMessageIndex].searchingItems.push(data.content);
+
+            } else if ((data.type === 'answer' || !data.type) && data.content) {
+              messages.value[aiMessageIndex].phase = 'answer';
               messages.value[aiMessageIndex].content += data.content;
-              charQueue += data.content;
-              if (!isTyping) {
-                isTyping = true;
-                typeNextChar();
-              }
+              answerQueue += data.content;
+              if (!isAnswerTyping) { isAnswerTyping = true; typeNextAnswerChar(); }
             }
+
             if (data.finished) break;
           } catch (e) {
             console.warn('Failed to parse JSON:', jsonStr, e);
@@ -146,12 +189,12 @@ export const useChatStore = defineStore('chat', () => {
 
       reader.releaseLock();
 
-      // 等待打字队列清空后再关闭光标
       const waitTypingDone = () => {
-        if (charQueue.length > 0) {
+        if (answerQueue.length > 0 || thinkingQueue.length > 0) {
           setTimeout(waitTypingDone, 50);
         } else {
           messages.value[aiMessageIndex].isStreaming = false;
+          messages.value[aiMessageIndex].phase = 'done';
         }
       };
       waitTypingDone();
@@ -164,4 +207,4 @@ export const useChatStore = defineStore('chat', () => {
   };
 
   return { messages, isLoading, loadChatHistory, sendMessage };
-});
+});

+ 81 - 20
chat-ai-ui-main/src/views/ChatView.vue

@@ -92,30 +92,89 @@ watch(
           <!-- 聊天信息 -->
           <template v-for="(msg, index) in chatStore.messages" :key="index">
           <div
-            v-if="!msg.isStreaming || msg.displayContent"
+            v-if="!msg.isStreaming || msg.displayContent || msg.thinkingDisplayContent || msg.searchingItems.length > 0"
             class="flex items-start"
             :class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
           >
-            <!-- 图标 -->
-            <div v-if="msg.role !== 'user'" class="flex-shrink-0 mr-3">
+            <!-- AI 图标 -->
+            <div v-if="msg.role !== 'user'" class="flex-shrink-0 mr-3 mt-1">
               <div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">
                 AI
               </div>
             </div>
 
-            <!-- 消息内容 -->
+            <!-- 用户消息气泡 -->
             <div
-              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'
-              "
+              v-if="msg.role === 'user'"
+              class="max-w-2xl px-4 py-3 rounded-lg shadow-sm prose prose-invert max-w-none relative bg-blue-600 text-white rounded-br-sm prose-headings:text-white prose-p:text-white prose-strong:text-white prose-em:text-white"
             >
               <div v-html="formatMessage(msg.displayContent || msg.content, msg.role)"></div>
-              <span v-if="msg.isStreaming" class="typing-cursor"></span>
             </div>
-            <!-- 用户 -->
+
+            <!-- AI 消息:三阶段分块 -->
+            <div v-else class="flex flex-col gap-2 min-w-0" style="max-width: 42rem">
+
+              <!-- ① 思考块(紫色,可折叠) -->
+              <div v-if="msg.thinkingContent" class="rounded-lg border border-purple-700/40 bg-purple-950/50 overflow-hidden">
+                <button
+                  @click="msg.isThinkingCollapsed = !msg.isThinkingCollapsed"
+                  class="flex items-center gap-2 w-full px-3 py-2 text-purple-300 text-sm font-medium hover:bg-purple-900/30 transition-colors text-left select-none"
+                >
+                  <span
+                    class="text-purple-500 text-xs transition-transform duration-200"
+                    :class="{ 'rotate-90': !msg.isThinkingCollapsed }"
+                  >▶</span>
+                  <span>💭 思考过程</span>
+                  <!-- 思考中动画点 -->
+                  <span v-if="msg.phase === 'thinking'" class="flex items-center gap-0.5 ml-1">
+                    <span class="w-1 h-1 bg-purple-400 rounded-full animate-bounce" style="animation-delay:0s"></span>
+                    <span class="w-1 h-1 bg-purple-400 rounded-full animate-bounce" style="animation-delay:0.15s"></span>
+                    <span class="w-1 h-1 bg-purple-400 rounded-full animate-bounce" style="animation-delay:0.3s"></span>
+                  </span>
+                  <span class="ml-auto text-purple-600 text-xs">{{ msg.isThinkingCollapsed ? '展开' : '收起' }}</span>
+                </button>
+                <div
+                  v-show="!msg.isThinkingCollapsed"
+                  class="px-4 pt-2 pb-3 border-t border-purple-700/30 text-purple-200/80 text-sm italic leading-relaxed"
+                >
+                  <div v-html="formatMessage(msg.thinkingDisplayContent, 'assistant')"></div>
+                  <span v-if="msg.phase === 'thinking' && msg.isStreaming" class="typing-cursor thinking-cursor"></span>
+                </div>
+              </div>
+
+              <!-- ② 搜索块(琥珀色) -->
+              <div v-if="msg.searchingItems.length > 0" class="rounded-lg border border-amber-700/40 bg-amber-950/40 px-3 py-2">
+                <div class="flex items-center gap-1.5 text-amber-300 text-xs font-semibold mb-2">
+                  <span>🔍</span>
+                  <span>{{ msg.phase === 'searching' ? '正在搜索' : '已搜索' }}</span>
+                  <span v-if="msg.phase === 'searching'" class="w-1.5 h-1.5 bg-amber-400 rounded-full animate-pulse"></span>
+                </div>
+                <div class="flex flex-wrap gap-1.5">
+                  <span
+                    v-for="(item, i) in msg.searchingItems"
+                    :key="i"
+                    class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors"
+                    :class="msg.phase === 'searching' && i === msg.searchingItems.length - 1
+                      ? 'bg-amber-800/50 border-amber-500 text-amber-100 animate-pulse'
+                      : 'bg-amber-900/40 border-amber-700/50 text-amber-200'"
+                  >
+                    <span v-if="msg.phase !== 'searching' || i < msg.searchingItems.length - 1">✓</span>
+                    {{ item }}
+                  </span>
+                </div>
+              </div>
+
+              <!-- ③ 正式回答(灰色气泡) -->
+              <div
+                v-if="msg.displayContent"
+                class="px-4 py-3 rounded-lg shadow-sm prose prose-invert max-w-none relative 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 v-html="formatMessage(msg.displayContent, msg.role)"></div>
+                <span v-if="msg.isStreaming && msg.phase === 'answer'" class="typing-cursor"></span>
+              </div>
+            </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">
@@ -124,8 +183,8 @@ watch(
           </div>
           </template>
 
-          <!-- 等待回复过程:仅在 loading 且还没有流式内容时显示 -->
-          <div v-if="chatStore.isLoading && !chatStore.messages.some(m => m.isStreaming && m.displayContent)" class="flex justify-start">
+          <!-- 等待回复过程:仅在 loading 且还没有任何流式内容时显示 -->
+          <div v-if="chatStore.isLoading && !chatStore.messages.some(m => m.isStreaming && (m.displayContent || m.thinkingDisplayContent || m.searchingItems.length > 0))" class="flex justify-start">
             <div class="flex-shrink-0 mr-3">
               <div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">
                 AI
@@ -338,15 +397,17 @@ watch(
   height: 1em;
   background-color: currentColor;
   margin-left: 2px;
+  vertical-align: text-bottom;
   animation: blink 1s infinite;
 }
 
+/* 思考块内紫色光标 */
+.thinking-cursor {
+  background-color: #a78bfa; /* purple-400 */
+}
+
 @keyframes blink {
-  0%, 49% {
-    opacity: 1;
-  }
-  50%, 100% {
-    opacity: 0;
-  }
+  0%, 49% { opacity: 1; }
+  50%, 100% { opacity: 0; }
 }
 </style>