Bladeren bron

新增知识库RAG问答逐字回答

余思翰 1 dag geleden
bovenliggende
commit
4d64b8dd64

+ 1
- 1
llm-back/ruoyi-admin/src/main/resources/application.yml Bestand weergeven

16
 # 开发环境配置
16
 # 开发环境配置
17
 server:
17
 server:
18
   # 服务器的HTTP端口,默认为8080
18
   # 服务器的HTTP端口,默认为8080
19
-  port: 8080
19
+  port: 8081
20
   servlet:
20
   servlet:
21
     # 应用的访问路径
21
     # 应用的访问路径
22
     context-path: /
22
     context-path: /

+ 166
- 2
llm-ui/src/api/llm/rag.js Bestand weergeven

1
 /*
1
 /*
2
  * @Author: ysh
2
  * @Author: ysh
3
  * @Date: 2025-07-08 16:43:22
3
  * @Date: 2025-07-08 16:43:22
4
- * @LastEditors: 
5
- * @LastEditTime: 2025-07-08 16:45:06
4
+ * @LastEditors: Please set LastEditors
5
+ * @LastEditTime: 2025-07-11 14:15:01
6
  */
6
  */
7
 import request from '@/utils/request'
7
 import request from '@/utils/request'
8
+import { getToken } from '@/utils/auth'
8
 
9
 
9
 // 查询cmc聊天记录列表
10
 // 查询cmc聊天记录列表
10
 export function getAnswer(question, collectionName) {
11
 export function getAnswer(question, collectionName) {
13
     method: 'get',
14
     method: 'get',
14
     params: { question, collectionName }
15
     params: { question, collectionName }
15
   })
16
   })
17
+}
18
+
19
+// 流式回答API - 使用fetch API处理流式响应
20
+export function getAnswerStream(question, collectionName, onMessage, onError, onComplete) {
21
+  const baseURL = import.meta.env.VITE_APP_BASE_API
22
+  const url = `${baseURL}/llm/rag/answer?question=${encodeURIComponent(question)}&collectionName=${encodeURIComponent(collectionName)}`
23
+
24
+  const controller = new AbortController()
25
+
26
+  fetch(url, {
27
+    method: 'GET',
28
+    headers: {
29
+      'Authorization': 'Bearer ' + getToken(),
30
+      'Accept': 'application/json, text/event-stream',
31
+      'Cache-Control': 'no-cache'
32
+    },
33
+    signal: controller.signal
34
+  }).then(response => {
35
+    if (!response.ok) {
36
+      throw new Error(`HTTP error! status: ${response.status}`)
37
+    }
38
+
39
+    const reader = response.body.getReader()
40
+    const decoder = new TextDecoder()
41
+    let buffer = ''
42
+
43
+    function readStream() {
44
+      return reader.read().then(({ done, value }) => {
45
+        if (done) {
46
+          console.log('=== 流式读取完成 ===')
47
+          // 处理缓冲区中剩余的数据
48
+          if (buffer.trim()) {
49
+            console.log('=== 处理剩余缓冲区数据 ===', buffer)
50
+            const lines = buffer.split(/\r?\n/)
51
+            lines.forEach(line => {
52
+              line = line.trim()
53
+              if (!line || line.startsWith(':')) return
54
+
55
+              console.log('处理剩余数据行:', line)
56
+
57
+              // 尝试提取JSON数据
58
+              let jsonData = null
59
+
60
+              if (line.startsWith('data: ')) {
61
+                try {
62
+                  jsonData = JSON.parse(line.slice(6))
63
+                  console.log('解析的剩余SSE数据:', jsonData)
64
+                } catch (error) {
65
+                  console.error('解析剩余SSE数据失败:', error, line)
66
+                }
67
+              } else if (line.startsWith('data:')) {
68
+                try {
69
+                  jsonData = JSON.parse(line.slice(5))
70
+                  console.log('解析的剩余SSE数据(无空格):', jsonData)
71
+                } catch (error) {
72
+                  console.error('解析剩余SSE数据失败(无空格):', error, line)
73
+                }
74
+              } else {
75
+                try {
76
+                  jsonData = JSON.parse(line)
77
+                  console.log('解析的剩余JSON数据:', jsonData)
78
+                } catch (error) {
79
+                  console.error('解析剩余JSON数据失败:', error, line)
80
+                }
81
+              }
82
+
83
+              // 处理解析成功的数据
84
+              if (jsonData) {
85
+                console.log('=== 解析成功的剩余数据 ===', jsonData)
86
+
87
+                if (jsonData.resultContent) {
88
+                  console.log('=== 准备发送剩余resultContent ===', jsonData.resultContent)
89
+                  onMessage(jsonData.resultContent)
90
+                } else if (typeof jsonData === 'string') {
91
+                  console.log('=== 准备发送剩余字符串 ===', jsonData)
92
+                  onMessage(jsonData)
93
+                } else {
94
+                  console.log('=== 剩余数据格式不匹配,跳过content字段 ===', jsonData)
95
+                }
96
+              }
97
+            })
98
+          }
99
+
100
+          onComplete()
101
+          return
102
+        }
103
+
104
+        const chunk = decoder.decode(value, { stream: true })
105
+        console.log('接收到原始数据块:', chunk)
106
+        buffer += chunk
107
+
108
+        // 处理可能包含\r\n的情况
109
+        const lines = buffer.split(/\r?\n/)
110
+
111
+        // 保留最后一行,因为它可能不完整
112
+        buffer = lines.pop() || ''
113
+
114
+        console.log('处理的行数:', lines.length)
115
+        lines.forEach(line => {
116
+          line = line.trim()
117
+          if (!line || line.startsWith(':')) return
118
+
119
+          console.log('处理数据行:', line)
120
+
121
+          // 尝试提取JSON数据
122
+          let jsonData = null
123
+
124
+          if (line.startsWith('data: ')) {
125
+            try {
126
+              jsonData = JSON.parse(line.slice(6))
127
+              console.log('解析的SSE数据:', jsonData)
128
+            } catch (error) {
129
+              console.error('解析SSE数据失败:', error, line)
130
+            }
131
+          } else if (line.startsWith('data:')) {
132
+            try {
133
+              jsonData = JSON.parse(line.slice(5))
134
+              console.log('解析的SSE数据(无空格):', jsonData)
135
+            } catch (error) {
136
+              console.error('解析SSE数据失败(无空格):', error, line)
137
+            }
138
+          } else {
139
+            try {
140
+              jsonData = JSON.parse(line)
141
+              console.log('解析的JSON数据:', jsonData)
142
+            } catch (error) {
143
+              console.error('解析JSON数据失败:', error, line)
144
+            }
145
+          }
146
+
147
+          // 处理解析成功的数据
148
+          if (jsonData) {
149
+            console.log('=== 解析成功的数据 ===', jsonData)
150
+
151
+            if (jsonData.resultContent) {
152
+              console.log('=== 准备发送resultContent ===', jsonData.resultContent)
153
+              onMessage(jsonData.resultContent)
154
+            } else if (typeof jsonData === 'string') {
155
+              console.log('=== 准备发送字符串 ===', jsonData)
156
+              onMessage(jsonData)
157
+            } else {
158
+              console.log('=== 数据格式不匹配,跳过content字段 ===', jsonData)
159
+            }
160
+          }
161
+        })
162
+
163
+        return readStream()
164
+      })
165
+    }
166
+
167
+    return readStream()
168
+  })
169
+    .catch(error => {
170
+      if (error.name === 'AbortError') {
171
+        console.log('请求被取消')
172
+        return
173
+      }
174
+      console.error('流式请求错误:', error)
175
+      onError(new Error('网络连接失败,请检查网络连接后重试'))
176
+    })
177
+
178
+  // 返回controller以便外部可以取消请求
179
+  return controller
16
 }
180
 }

+ 161
- 30
llm-ui/src/views/llm/knowledge/index.vue Bestand weergeven

167
             </div>
167
             </div>
168
 
168
 
169
             <div v-else class="message-list">
169
             <div v-else class="message-list">
170
-              <div v-for="(message, index) in chatMessages" :key="index" class="message-item" :class="message.type">
170
+              <div v-for="(message, index) in chatMessages" :key="index" class="message-item" 
171
+                :class="[message.type, { 'typing': message.type === 'ai' && isSending && index === chatMessages.length - 1 }]">
171
                 <div class="message-avatar">
172
                 <div class="message-avatar">
172
                   <div v-if="message.type === 'user'" class="user-avatar">
173
                   <div v-if="message.type === 'user'" class="user-avatar">
173
                     <el-icon>
174
                     <el-icon>
187
               </div>
188
               </div>
188
 
189
 
189
               <!-- AI回答loading状态 -->
190
               <!-- AI回答loading状态 -->
190
-              <div v-if="isSending" class="message-item ai">
191
+              <div v-if="isSending && chatMessages.length > 0 && chatMessages[chatMessages.length - 1].type === 'ai' && chatMessages[chatMessages.length - 1].content === ''" class="message-item ai">
191
                 <div class="message-avatar">
192
                 <div class="message-avatar">
192
                   <div class="ai-avatar">
193
                   <div class="ai-avatar">
193
                     <el-icon>
194
                     <el-icon>
211
           <!-- 输入区域 -->
212
           <!-- 输入区域 -->
212
           <div class="chat-input-area">
213
           <div class="chat-input-area">
213
             <div class="input-container">
214
             <div class="input-container">
214
-              <el-input v-model="chatInput" type="textarea" :rows="3" placeholder="请输入您的问题..."
215
+              <el-input v-model="chatInput" type="textarea" :rows="3" 
216
+                :placeholder="isSending ? 'AI正在思考中...' : '请输入您的问题...'"
215
                 @keydown.enter="handleEnter" @keydown.ctrl.enter="handleCtrlEnter" :disabled="isSending"
217
                 @keydown.enter="handleEnter" @keydown.ctrl.enter="handleCtrlEnter" :disabled="isSending"
216
                 resize="none" />
218
                 resize="none" />
217
               <div class="input-actions">
219
               <div class="input-actions">
218
-                <el-button type="primary" :icon="Promotion" @click="sendMessage" :loading="isSending"
219
-                  :disabled="!chatInput.trim() || isSending">
220
+                <el-button v-if="!isSending" type="primary" :icon="Promotion" @click="sendMessage"
221
+                  :disabled="!chatInput.trim() || chatInput.length > 2000">
220
                   发送
222
                   发送
221
                 </el-button>
223
                 </el-button>
224
+                <el-button v-else type="danger" :icon="Delete" @click="stopGenerating">
225
+                  停止生成
226
+                </el-button>
222
               </div>
227
               </div>
223
             </div>
228
             </div>
224
             <div class="input-tips">
229
             <div class="input-tips">
225
               <span>按 Enter 发送,Ctrl + Enter 换行</span>
230
               <span>按 Enter 发送,Ctrl + Enter 换行</span>
231
+              <span v-if="chatInput" class="char-count" :class="{ 'warning': chatInput.length > 1500 }">
232
+                {{ chatInput.length }} 字符
233
+                <span v-if="chatInput.length > 2000" class="error-text">(超出限制)</span>
234
+              </span>
226
             </div>
235
             </div>
227
           </div>
236
           </div>
228
         </div>
237
         </div>
285
 </template>
294
 </template>
286
 
295
 
287
 <script setup>
296
 <script setup>
288
-import { ref, reactive, getCurrentInstance, onMounted, nextTick, watch, computed } from 'vue'
297
+import { ref, reactive, getCurrentInstance, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
289
 import { Search, Refresh, Plus, Edit, Delete, Upload, UploadFilled, Folder, MoreFilled, FolderOpened, Document, ChatRound, User, ChatDotRound, Promotion } from '@element-plus/icons-vue'
298
 import { Search, Refresh, Plus, Edit, Delete, Upload, UploadFilled, Folder, MoreFilled, FolderOpened, Document, ChatRound, User, ChatDotRound, Promotion } from '@element-plus/icons-vue'
290
 import { listKnowledge, addKnowledge, updateKnowledge, delKnowledge, insertKnowledgeFile, listKnowledgeDocument, deleteKnowledgeFile } from "@/api/llm/knowLedge";
299
 import { listKnowledge, addKnowledge, updateKnowledge, delKnowledge, insertKnowledgeFile, listKnowledgeDocument, deleteKnowledgeFile } from "@/api/llm/knowLedge";
291
-import { getAnswer } from '@/api/llm/rag';
300
+import { getAnswer, getAnswerStream } from '@/api/llm/rag';
292
 import { getToken } from "@/utils/auth";
301
 import { getToken } from "@/utils/auth";
293
 
302
 
294
 const { proxy } = getCurrentInstance();
303
 const { proxy } = getCurrentInstance();
378
 
387
 
379
 /** 选择知识库 */
388
 /** 选择知识库 */
380
 function selectKnowledge(knowledge) {
389
 function selectKnowledge(knowledge) {
390
+  // 如果正在生成回答,先停止
391
+  if (isSending.value) {
392
+    stopGenerating();
393
+  }
394
+  
381
   selectedKnowledge.value = knowledge;
395
   selectedKnowledge.value = knowledge;
382
   // 重置聊天模式
396
   // 重置聊天模式
383
   isChatMode.value = false;
397
   isChatMode.value = false;
400
     proxy.$modal.msgWarning("请先选择要对话的知识库");
414
     proxy.$modal.msgWarning("请先选择要对话的知识库");
401
     return;
415
     return;
402
   }
416
   }
417
+  // 如果正在生成回答,先停止
418
+  if (isSending.value) {
419
+    stopGenerating();
420
+  }
421
+  
403
   isChatMode.value = true;
422
   isChatMode.value = true;
404
   chatMessages.value = [];
423
   chatMessages.value = [];
405
   chatInput.value = '';
424
   chatInput.value = '';
411
 
430
 
412
 /** 切换到文件模式 */
431
 /** 切换到文件模式 */
413
 function switchToFileMode() {
432
 function switchToFileMode() {
433
+  // 如果正在生成回答,先停止
434
+  if (isSending.value) {
435
+    stopGenerating();
436
+  }
437
+  
414
   isChatMode.value = false;
438
   isChatMode.value = false;
415
 }
439
 }
416
 
440
 
417
 /** 发送消息 */
441
 /** 发送消息 */
418
 function sendMessage() {
442
 function sendMessage() {
419
-  if (!chatInput.value.trim() || isSending.value) return;
443
+  if (!chatInput.value.trim()) return;
444
+  
445
+  // 如果正在发送,先停止当前请求
446
+  if (isSending.value) {
447
+    stopGenerating();
448
+  }
420
 
449
 
421
   const userMessage = {
450
   const userMessage = {
422
     type: 'user',
451
     type: 'user',
434
     scrollToBottom();
463
     scrollToBottom();
435
   });
464
   });
436
 
465
 
437
-  // 延迟一点时间让loading动画显示
438
-  setTimeout(() => {
439
-    getAnswer(currentInput, selectedKnowledge.value.collectionName).then(response => {
440
-      console.log(response);
441
-      const aiMessage = {
442
-        type: 'ai',
443
-        content: response.resultContent,
444
-        time: new Date()
445
-      };
446
-      chatMessages.value.push(aiMessage);
447
-      isSending.value = false;
466
+  // 创建AI消息占位符
467
+  const aiMessage = reactive({
468
+    type: 'ai',
469
+    content: '',
470
+    time: new Date()
471
+  });
472
+  chatMessages.value.push(aiMessage);
473
+
474
+  // 使用流式API获取回答
475
+  const eventSource = getAnswerStream(
476
+    currentInput, 
477
+    selectedKnowledge.value.collectionName,
478
+        // onMessage: 接收到每个字符时的回调
479
+    (content) => {
480
+      // 处理接收到的内容
481
+      console.log('=== 前端接收到内容 ===', content)
482
+      
483
+      // 清理内容中的</think>标签
484
+      let cleanContent = content.replace(/<\/?think>/g, '');
485
+      
486
+      // 如果内容为空或只包含空白字符,跳过
487
+      if (!cleanContent.trim()) {
488
+        return;
489
+      }
490
+      
491
+      // 直接替换内容,避免重复叠加
492
+      aiMessage.content = cleanContent;
493
+      
494
+      console.log('=== 清理后的内容 ===', cleanContent)
495
+      console.log('=== 当前AI消息完整内容 ===', aiMessage.content)
496
+      
448
       // 滚动到底部
497
       // 滚动到底部
449
       nextTick(() => {
498
       nextTick(() => {
450
         scrollToBottom();
499
         scrollToBottom();
451
       });
500
       });
452
-    }).catch(error => {
453
-      console.log(error);
454
-      // 显示错误消息
455
-      const errorMessage = {
456
-        type: 'ai',
457
-        content: '抱歉,我暂时无法回答您的问题,请稍后再试。',
458
-        time: new Date()
459
-      };
460
-      chatMessages.value.push(errorMessage);
501
+    },
502
+    // onError: 发生错误时的回调
503
+    (error) => {
504
+      console.error('=== 流式回答错误 ===', error);
505
+      if (aiMessage.content === '') {
506
+        aiMessage.content = '抱歉,我暂时无法回答您的问题,请稍后再试。';
507
+      } else {
508
+        aiMessage.content += '\n\n[回答生成中断]';
509
+      }
461
       isSending.value = false;
510
       isSending.value = false;
511
+      console.log('=== 错误时isSending设置为false ===')
462
       // 滚动到底部
512
       // 滚动到底部
463
       nextTick(() => {
513
       nextTick(() => {
464
         scrollToBottom();
514
         scrollToBottom();
465
       });
515
       });
466
-    });
467
-  }, 300);
516
+    },
517
+    // onComplete: 回答完成时的回调
518
+    () => {
519
+      console.log('=== 回答完成 ===')
520
+      isSending.value = false;
521
+      console.log('=== isSending设置为false ===')
522
+      
523
+      // 确保状态更新
524
+      nextTick(() => {
525
+        console.log('=== 最终isSending状态 ===', isSending.value)
526
+        scrollToBottom();
527
+      });
528
+    }
529
+  );
530
+
531
+  // 如果用户快速发送多条消息,取消之前的请求
532
+  if (window.currentController) {
533
+    window.currentController.abort();
534
+  }
535
+  window.currentController = eventSource;
536
+  
537
+  // 添加超时机制,确保状态能够正确切换
538
+  setTimeout(() => {
539
+    if (isSending.value) {
540
+      console.log('=== 超时强制结束 ===')
541
+      isSending.value = false;
542
+      if (window.currentController) {
543
+        window.currentController.abort();
544
+        window.currentController = null;
545
+      }
546
+    }
547
+  }, 10000); // 10秒超时
548
+}
549
+
550
+/** 停止生成回答 */
551
+function stopGenerating() {
552
+  if (window.currentController) {
553
+    window.currentController.abort();
554
+    window.currentController = null;
555
+  }
556
+  isSending.value = false;
468
 }
557
 }
469
 
558
 
470
 /** 滚动到底部 */
559
 /** 滚动到底部 */
649
     getList();
738
     getList();
650
     // 如果删除的是当前选中的知识库,清空选择
739
     // 如果删除的是当前选中的知识库,清空选择
651
     if (selectedKnowledge.value?.collectionName === collectionNames) {
740
     if (selectedKnowledge.value?.collectionName === collectionNames) {
741
+      // 如果正在生成回答,先停止
742
+      if (isSending.value) {
743
+        stopGenerating();
744
+      }
652
       selectedKnowledge.value = null;
745
       selectedKnowledge.value = null;
653
       fileList.value = [];
746
       fileList.value = [];
654
       isChatMode.value = false;
747
       isChatMode.value = false;
723
 onMounted(() => {
816
 onMounted(() => {
724
   getList();
817
   getList();
725
 });
818
 });
819
+
820
+// 组件卸载时清理请求控制器
821
+onUnmounted(() => {
822
+  if (window.currentController) {
823
+    window.currentController.abort();
824
+    window.currentController = null;
825
+  }
826
+});
726
 </script>
827
 </script>
727
 
828
 
728
 <style lang="scss" scoped>
829
 <style lang="scss" scoped>
1053
 
1154
 
1054
   .message-text {
1155
   .message-text {
1055
     margin-bottom: 8px;
1156
     margin-bottom: 8px;
1157
+    white-space: pre-wrap;
1056
   }
1158
   }
1057
 
1159
 
1058
   .message-time {
1160
   .message-time {
1061
   }
1163
   }
1062
 }
1164
 }
1063
 
1165
 
1166
+// 打字机效果的光标 - 只在正在生成时显示
1167
+.message-bubble.ai.typing .message-text:after {
1168
+  content: '|';
1169
+  animation: blink 1s infinite;
1170
+  color: #409eff;
1171
+}
1172
+
1173
+@keyframes blink {
1174
+  0%, 50% { opacity: 1; }
1175
+  51%, 100% { opacity: 0; }
1176
+}
1177
+
1064
 .chat-input-area {
1178
 .chat-input-area {
1065
   padding: 20px;
1179
   padding: 20px;
1066
   border-top: 1px solid #e4e7ed;
1180
   border-top: 1px solid #e4e7ed;
1086
   text-align: center;
1200
   text-align: center;
1087
   font-size: 12px;
1201
   font-size: 12px;
1088
   color: #999;
1202
   color: #999;
1203
+  display: flex;
1204
+  justify-content: space-between;
1205
+  align-items: center;
1206
+  
1207
+  .char-count {
1208
+    color: #409eff;
1209
+    font-weight: 500;
1210
+    
1211
+    &.warning {
1212
+      color: #e6a23c;
1213
+    }
1214
+    
1215
+    .error-text {
1216
+      color: #f56c6c;
1217
+      font-size: 11px;
1218
+    }
1219
+  }
1089
 }
1220
 }
1090
 
1221
 
1091
 .file-list {
1222
 .file-list {

+ 4
- 4
llm-ui/vite.config.js Bestand weergeven

1
 /*
1
 /*
2
  * @Author: wrh
2
  * @Author: wrh
3
  * @Date: 2025-04-07 12:47:46
3
  * @Date: 2025-04-07 12:47:46
4
- * @LastEditors: wrh
5
- * @LastEditTime: 2025-04-08 09:02:00
4
+ * @LastEditors: Please set LastEditors
5
+ * @LastEditTime: 2025-07-11 11:03:27
6
  */
6
  */
7
 import { defineConfig, loadEnv } from 'vite'
7
 import { defineConfig, loadEnv } from 'vite'
8
 import path from 'path'
8
 import path from 'path'
31
     },
31
     },
32
     // vite 相关配置
32
     // vite 相关配置
33
     server: {
33
     server: {
34
-      port: 81,
34
+      port: 84,
35
       host: true,
35
       host: true,
36
       open: true,
36
       open: true,
37
       proxy: {
37
       proxy: {
38
         // https://cn.vitejs.dev/config/#server-proxy
38
         // https://cn.vitejs.dev/config/#server-proxy
39
         '/dev-api': {
39
         '/dev-api': {
40
-          target: 'http://localhost:8080',
40
+          target: 'http://localhost:8081',
41
           changeOrigin: true,
41
           changeOrigin: true,
42
           rewrite: (p) => p.replace(/^\/dev-api/, '')
42
           rewrite: (p) => p.replace(/^\/dev-api/, '')
43
         }
43
         }

Laden…
Annuleren
Opslaan