Переглянути джерело

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

余思翰 1 день тому
джерело
коміт
4d64b8dd64

+ 1
- 1
llm-back/ruoyi-admin/src/main/resources/application.yml Переглянути файл

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

+ 166
- 2
llm-ui/src/api/llm/rag.js Переглянути файл

@@ -1,10 +1,11 @@
1 1
 /*
2 2
  * @Author: ysh
3 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 7
 import request from '@/utils/request'
8
+import { getToken } from '@/utils/auth'
8 9
 
9 10
 // 查询cmc聊天记录列表
10 11
 export function getAnswer(question, collectionName) {
@@ -13,4 +14,167 @@ export function getAnswer(question, collectionName) {
13 14
     method: 'get',
14 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 Переглянути файл

@@ -167,7 +167,8 @@
167 167
             </div>
168 168
 
169 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 172
                 <div class="message-avatar">
172 173
                   <div v-if="message.type === 'user'" class="user-avatar">
173 174
                     <el-icon>
@@ -187,7 +188,7 @@
187 188
               </div>
188 189
 
189 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 192
                 <div class="message-avatar">
192 193
                   <div class="ai-avatar">
193 194
                     <el-icon>
@@ -211,18 +212,26 @@
211 212
           <!-- 输入区域 -->
212 213
           <div class="chat-input-area">
213 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 217
                 @keydown.enter="handleEnter" @keydown.ctrl.enter="handleCtrlEnter" :disabled="isSending"
216 218
                 resize="none" />
217 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 223
                 </el-button>
224
+                <el-button v-else type="danger" :icon="Delete" @click="stopGenerating">
225
+                  停止生成
226
+                </el-button>
222 227
               </div>
223 228
             </div>
224 229
             <div class="input-tips">
225 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 235
             </div>
227 236
           </div>
228 237
         </div>
@@ -285,10 +294,10 @@
285 294
 </template>
286 295
 
287 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 298
 import { Search, Refresh, Plus, Edit, Delete, Upload, UploadFilled, Folder, MoreFilled, FolderOpened, Document, ChatRound, User, ChatDotRound, Promotion } from '@element-plus/icons-vue'
290 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 301
 import { getToken } from "@/utils/auth";
293 302
 
294 303
 const { proxy } = getCurrentInstance();
@@ -378,6 +387,11 @@ function getList() {
378 387
 
379 388
 /** 选择知识库 */
380 389
 function selectKnowledge(knowledge) {
390
+  // 如果正在生成回答,先停止
391
+  if (isSending.value) {
392
+    stopGenerating();
393
+  }
394
+  
381 395
   selectedKnowledge.value = knowledge;
382 396
   // 重置聊天模式
383 397
   isChatMode.value = false;
@@ -400,6 +414,11 @@ function handleChat(knowledge) {
400 414
     proxy.$modal.msgWarning("请先选择要对话的知识库");
401 415
     return;
402 416
   }
417
+  // 如果正在生成回答,先停止
418
+  if (isSending.value) {
419
+    stopGenerating();
420
+  }
421
+  
403 422
   isChatMode.value = true;
404 423
   chatMessages.value = [];
405 424
   chatInput.value = '';
@@ -411,12 +430,22 @@ function handleChat(knowledge) {
411 430
 
412 431
 /** 切换到文件模式 */
413 432
 function switchToFileMode() {
433
+  // 如果正在生成回答,先停止
434
+  if (isSending.value) {
435
+    stopGenerating();
436
+  }
437
+  
414 438
   isChatMode.value = false;
415 439
 }
416 440
 
417 441
 /** 发送消息 */
418 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 450
   const userMessage = {
422 451
     type: 'user',
@@ -434,37 +463,97 @@ function sendMessage() {
434 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 498
       nextTick(() => {
450 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 510
       isSending.value = false;
511
+      console.log('=== 错误时isSending设置为false ===')
462 512
       // 滚动到底部
463 513
       nextTick(() => {
464 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,6 +738,10 @@ function handleDelete(row) {
649 738
     getList();
650 739
     // 如果删除的是当前选中的知识库,清空选择
651 740
     if (selectedKnowledge.value?.collectionName === collectionNames) {
741
+      // 如果正在生成回答,先停止
742
+      if (isSending.value) {
743
+        stopGenerating();
744
+      }
652 745
       selectedKnowledge.value = null;
653 746
       fileList.value = [];
654 747
       isChatMode.value = false;
@@ -723,6 +816,14 @@ function cancelUpload() {
723 816
 onMounted(() => {
724 817
   getList();
725 818
 });
819
+
820
+// 组件卸载时清理请求控制器
821
+onUnmounted(() => {
822
+  if (window.currentController) {
823
+    window.currentController.abort();
824
+    window.currentController = null;
825
+  }
826
+});
726 827
 </script>
727 828
 
728 829
 <style lang="scss" scoped>
@@ -1053,6 +1154,7 @@ onMounted(() => {
1053 1154
 
1054 1155
   .message-text {
1055 1156
     margin-bottom: 8px;
1157
+    white-space: pre-wrap;
1056 1158
   }
1057 1159
 
1058 1160
   .message-time {
@@ -1061,6 +1163,18 @@ onMounted(() => {
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 1178
 .chat-input-area {
1065 1179
   padding: 20px;
1066 1180
   border-top: 1px solid #e4e7ed;
@@ -1086,6 +1200,23 @@ onMounted(() => {
1086 1200
   text-align: center;
1087 1201
   font-size: 12px;
1088 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 1222
 .file-list {

+ 4
- 4
llm-ui/vite.config.js Переглянути файл

@@ -1,8 +1,8 @@
1 1
 /*
2 2
  * @Author: wrh
3 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 7
 import { defineConfig, loadEnv } from 'vite'
8 8
 import path from 'path'
@@ -31,13 +31,13 @@ export default defineConfig(({ mode, command }) => {
31 31
     },
32 32
     // vite 相关配置
33 33
     server: {
34
-      port: 81,
34
+      port: 84,
35 35
       host: true,
36 36
       open: true,
37 37
       proxy: {
38 38
         // https://cn.vitejs.dev/config/#server-proxy
39 39
         '/dev-api': {
40
-          target: 'http://localhost:8080',
40
+          target: 'http://localhost:8081',
41 41
           changeOrigin: true,
42 42
           rewrite: (p) => p.replace(/^\/dev-api/, '')
43 43
         }

Завантаження…
Відмінити
Зберегти