Bläddra i källkod

大模型问答及知识库回答流式输出

lamphua 1 vecka sedan
förälder
incheckning
3869b12412

+ 1
- 1
oa-back/ruoyi-agent/pom.xml Visa fil

@@ -130,7 +130,7 @@
130 130
             <plugin>
131 131
                 <groupId>org.springframework.boot</groupId>
132 132
                 <artifactId>spring-boot-maven-plugin</artifactId>
133
-                <version>2.7.18</version>
133
+                <version>2.5.15</version>
134 134
                 <configuration>
135 135
                     <mainClass>com.ruoyi.agent.RuoYiAgentApplication</mainClass>
136 136
                 </configuration>

+ 3
- 3
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/RagController.java Visa fil

@@ -3,7 +3,7 @@ package com.ruoyi.web.llm.controller;
3 3
 import com.alibaba.fastjson2.JSONObject;
4 4
 import com.ruoyi.web.llm.service.ILangChainMilvusService;
5 5
 import com.ruoyi.common.core.controller.BaseController;
6
-import org.noear.solon.ai.chat.message.AssistantMessage;
6
+import org.noear.solon.core.util.MimeType;
7 7
 import org.springframework.beans.factory.annotation.Autowired;
8 8
 import org.springframework.web.bind.annotation.GetMapping;
9 9
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -29,8 +29,8 @@ public class RagController extends BaseController
29 29
     /**
30 30
      * 调用LLM+RAG(知识库)生成回答
31 31
      */
32
-    @GetMapping("/answer")
33
-    public Flux<AssistantMessage> answerWithCollection(String collectionName, String topicId, String question) throws IOException {
32
+    @GetMapping(value = "/answer", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
33
+    public Flux<String> answerWithCollection(String collectionName, String topicId, String question) throws IOException {
34 34
         List<JSONObject> contexts = langChainMilvusService.retrieveFromMilvus(collectionName, question, 10);
35 35
         return langChainMilvusService.generateAnswerWithCollection(topicId, question, contexts);
36 36
     }

+ 1
- 3
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/SessionController.java Visa fil

@@ -2,7 +2,6 @@ package com.ruoyi.web.llm.controller;
2 2
 
3 3
 import com.ruoyi.common.core.controller.BaseController;
4 4
 import com.ruoyi.web.llm.service.ISessionService;
5
-import org.noear.solon.ai.chat.message.AssistantMessage;
6 5
 import org.noear.solon.core.util.MimeType;
7 6
 import org.springframework.beans.factory.annotation.Autowired;
8 7
 import org.springframework.web.bind.annotation.GetMapping;
@@ -35,10 +34,9 @@ public class SessionController extends BaseController
35 34
      * 调用LLM+RAG(外部文件)生成回答
36 35
      */
37 36
     @GetMapping(value = "/answerWithDocument", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
38
-    public Flux<AssistantMessage> answerWithDocument(String topicId, String chatId, String question) throws Exception
37
+    public Flux<String> answerWithDocument(String topicId, String chatId, String question) throws Exception
39 38
     {
40 39
         return sessionService.answerWithDocument(topicId, chatId, question);
41 40
     }
42
-
43 41
 }
44 42
 

+ 4
- 5
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/ILangChainMilvusService.java Visa fil

@@ -1,7 +1,6 @@
1 1
 package com.ruoyi.web.llm.service;
2 2
 
3 3
 import com.alibaba.fastjson2.JSONObject;
4
-import org.noear.solon.ai.chat.message.AssistantMessage;
5 4
 import org.springframework.web.multipart.MultipartFile;
6 5
 import reactor.core.publisher.Flux;
7 6
 
@@ -39,25 +38,25 @@ public interface ILangChainMilvusService {
39 38
      * 调用LLM生成回答
40 39
      * @return
41 40
      */
42
-    public Flux<AssistantMessage> generateAnswer(String topicId, String question);
41
+    public Flux<String> generateAnswer(String topicId, String question);
43 42
 
44 43
     /**
45 44
      * 调用LLM+RAG(知识库)生成回答
46 45
      * @return
47 46
      */
48
-    public Flux<AssistantMessage> generateAnswerWithCollection(String topicId, String question, List<JSONObject> contexts);
47
+    public Flux<String> generateAnswerWithCollection(String topicId, String question, List<JSONObject> contexts);
49 48
 
50 49
     /**
51 50
      * 调用LLM+RAG(外部文件)生成回答
52 51
      * @return
53 52
      */
54
-    public Flux<AssistantMessage> generateAnswerWithDocument(String topicId, String chatId, String question) throws Exception;
53
+    public Flux<String> generateAnswerWithDocument(String topicId, String chatId, String question) throws Exception;
55 54
 
56 55
     /**
57 56
      * 调用LLM+RAG(外部文件+知识库)生成回答
58 57
      * @return
59 58
      */
60
-    public Flux<AssistantMessage> generateAnswerWithDocumentAndCollection(String topicId, String question,  List<JSONObject> requests) throws Exception;
59
+    public Flux<String> generateAnswerWithDocumentAndCollection(String topicId, String question,  List<JSONObject> requests) throws Exception;
61 60
 
62 61
     /**
63 62
      * 获取二级标题下三级标题列表

+ 1
- 2
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/ISessionService.java Visa fil

@@ -1,6 +1,5 @@
1 1
 package com.ruoyi.web.llm.service;
2 2
 
3
-import org.noear.solon.ai.chat.message.AssistantMessage;
4 3
 import reactor.core.publisher.Flux;
5 4
 
6 5
 public interface ISessionService {
@@ -13,6 +12,6 @@ public interface ISessionService {
13 12
     /**
14 13
      * 调用LLM+RAG(外部文件)生成回答
15 14
      */
16
-    Flux<AssistantMessage> answerWithDocument(String topicId, String chatId, String question) throws Exception;
15
+    Flux<String> answerWithDocument(String topicId, String chatId, String question) throws Exception;
17 16
 
18 17
 }

+ 38
- 11
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/LangChainMilvusServiceImpl.java Visa fil

@@ -40,8 +40,8 @@ import org.apache.poi.xwpf.usermodel.XWPFParagraph;
40 40
 import org.noear.solon.ai.chat.ChatModel;
41 41
 import org.noear.solon.ai.chat.ChatResponse;
42 42
 import org.noear.solon.ai.chat.ChatSession;
43
-import org.noear.solon.ai.chat.message.AssistantMessage;
44 43
 import org.noear.solon.ai.chat.message.ChatMessage;
44
+import org.noear.solon.ai.chat.prompt.Prompt;
45 45
 import org.noear.solon.ai.chat.session.InMemoryChatSession;
46 46
 import org.reactivestreams.Publisher;
47 47
 import org.springframework.beans.factory.annotation.Autowired;
@@ -84,10 +84,10 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
84 84
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
85 85
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
86 86
         }
87
-//        milvusClient = new MilvusClientV2(
88
-//                ConnectConfig.builder()
89
-//                        .uri(milvusServiceUrl)
90
-//                        .build());
87
+       milvusClient = new MilvusClientV2(
88
+               ConnectConfig.builder()
89
+                       .uri(milvusServiceUrl)
90
+                       .build());
91 91
     }
92 92
     
93 93
     @PostConstruct
@@ -267,7 +267,7 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
267 267
      * @return
268 268
      */
269 269
     @Override
270
-    public Flux<AssistantMessage> generateAnswer(String topicId, String prompt) {
270
+    public Flux<String> generateAnswer(String topicId, String prompt) {
271 271
         List<ChatMessage> messages = new ArrayList<>();
272 272
         if (topicId != null) {
273 273
             CmcChat cmcChat = new CmcChat();
@@ -280,9 +280,36 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
280 280
         }
281 281
         messages.add(ChatMessage.ofUser(prompt));
282 282
         ChatSession chatSession =  InMemoryChatSession.builder().messages(messages).build();
283
-        Publisher<ChatResponse> publisher = chatModel.prompt(chatSession).stream();
283
+        Prompt prompt1 = Prompt.of(prompt).attrPut("session", chatSession);
284
+        Publisher<ChatResponse> publisher = chatModel.prompt(prompt1).stream();
284 285
         return Flux.from(publisher)
285
-                .map(response -> response.lastChoice().getMessage());
286
+                .map(response -> response.lastChoice().getMessage().getContent())
287
+                .map(this::toSseDataFrame)
288
+                .concatWith(Flux.just("data: [DONE]\n\n"));
289
+    }
290
+
291
+    private String toSseDataFrame(String data) {
292
+        if (data == null) {
293
+            return "data: \n\n";
294
+        }
295
+        // SSE 要求每一行都以 data: 开头
296
+        String normalized = data.replace("\r\n", "\n");
297
+        StringBuilder sb = new StringBuilder(normalized.length() + 16);
298
+        sb.append("data: ");
299
+        int start = 0;
300
+        while (true) {
301
+            int idx = normalized.indexOf('\n', start);
302
+            if (idx < 0) {
303
+                sb.append(normalized.substring(start));
304
+                break;
305
+            }
306
+            sb.append(normalized, start, idx);
307
+            sb.append("\n");
308
+            sb.append("data: ");
309
+            start = idx + 1;
310
+        }
311
+        sb.append("\n\n");
312
+        return sb.toString();
286 313
     }
287 314
 
288 315
     /**
@@ -290,7 +317,7 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
290 317
      * @return
291 318
      */
292 319
     @Override
293
-    public Flux<AssistantMessage> generateAnswerWithCollection(String topicId, String question, List<JSONObject> contexts) {
320
+    public Flux<String> generateAnswerWithCollection(String topicId, String question, List<JSONObject> contexts) {
294 321
         StringBuilder sb = new StringBuilder();
295 322
         sb.append("问题: ").append(question).append("\n\n");
296 323
         sb.append("根据以下上下文回答问题:\n\n");
@@ -307,7 +334,7 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
307 334
      * 调用LLM生成回答
308 335
      */
309 336
     @Override
310
-    public Flux<AssistantMessage> generateAnswerWithDocument(String topicId, String chatId, String question) throws Exception {
337
+    public Flux<String> generateAnswerWithDocument(String topicId, String chatId, String question) throws Exception {
311 338
         CmcDocument cmcDocument = new CmcDocument();
312 339
         cmcDocument.setChatId(chatId);
313 340
         List<CmcDocument> documentList = cmcDocumentService.selectCmcDocumentList(cmcDocument);
@@ -340,7 +367,7 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
340 367
      * 调用LLM生成回答
341 368
      */
342 369
     @Override
343
-    public Flux<AssistantMessage> generateAnswerWithDocumentAndCollection(String topicId, String question, List<JSONObject> contexts) throws Exception {
370
+    public Flux<String> generateAnswerWithDocumentAndCollection(String topicId, String question, List<JSONObject> contexts) throws Exception {
344 371
         StringBuilder sb = new StringBuilder("招标文件内容:\n\n");
345 372
         CmcChat cmcChat = new CmcChat();
346 373
         cmcChat.setTopicId(topicId);

+ 4
- 4
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/MilvusServiceImpl.java Visa fil

@@ -35,10 +35,10 @@ public class MilvusServiceImpl implements IMilvusService {
35 35
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
36 36
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
37 37
         }
38
-//        milvusClient = new MilvusClientV2(
39
-//                ConnectConfig.builder()
40
-//                        .uri(milvusServiceUrl)
41
-//                        .build());
38
+       milvusClient = new MilvusClientV2(
39
+               ConnectConfig.builder()
40
+                       .uri(milvusServiceUrl)
41
+                       .build());
42 42
     }
43 43
     
44 44
     @PreDestroy

+ 5
- 3
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/SessionServiceImpl.java Visa fil

@@ -8,7 +8,6 @@ import com.ruoyi.web.llm.service.ISessionService;
8 8
 import org.noear.solon.ai.chat.ChatModel;
9 9
 import org.noear.solon.ai.chat.ChatResponse;
10 10
 import org.noear.solon.ai.chat.ChatSession;
11
-import org.noear.solon.ai.chat.message.AssistantMessage;
12 11
 import org.noear.solon.ai.chat.message.ChatMessage;
13 12
 import org.noear.solon.ai.chat.prompt.Prompt;
14 13
 import org.noear.solon.ai.chat.session.InMemoryChatSession;
@@ -34,11 +33,14 @@ public class SessionServiceImpl implements ISessionService {
34 33
     @Value("${cmc.llmService.url}")
35 34
     private String llmServiceUrl;
36 35
 
36
+    @Value("${cmc.mcpService.url}")
37
+    private String mcpServiceUrl;
38
+
37 39
     @Override
38 40
     public Flux<String> answer(String topicId, String question) {
39 41
         McpClientProvider clientProvider = McpClientProvider.builder()
40 42
                 .channel(McpChannel.STREAMABLE_STATELESS)
41
-                .url("http://localhost:8087/mcp/sse")
43
+                .url(mcpServiceUrl)
42 44
                 .build();
43 45
         ChatModel chatModel = ChatModel.of(llmServiceUrl)
44 46
                 .model("Qwen")
@@ -241,7 +243,7 @@ public class SessionServiceImpl implements ISessionService {
241 243
     }
242 244
 
243 245
     @Override
244
-    public Flux<AssistantMessage> answerWithDocument(String topicId, String chatId, String question) throws Exception {
246
+    public Flux<String> answerWithDocument(String topicId, String chatId, String question) throws Exception {
245 247
         return langChainMilvusService.generateAnswerWithDocument(topicId, chatId, question);
246 248
     }
247 249
 

+ 111
- 149
oa-ui/src/api/llm/rag.js Visa fil

@@ -25,171 +25,133 @@ export function getContextFile(question, collectionName) {
25 25
   })
26 26
 }
27 27
 
28
-// 流式回答API - 使用fetch API处理流式响应
29
-export function getAnswerStream(question, collectionName, onMessage, onError, onComplete) {
30
-  const baseURL = process.env.VUE_APP_BASE_API
31
-  const url = `${baseURL}/llm/rag/answer?question=${encodeURIComponent(question)}&collectionName=${encodeURIComponent(collectionName)}`
28
+function parseSseEvents(buffer) {
29
+  // SSE 事件以空行分隔:\n\n(兼容 \r\n)
30
+  const events = []
31
+  const normalized = buffer.replace(/\r\n/g, '\n')
32
+  const parts = normalized.split('\n\n')
33
+  // 最后一段可能是不完整事件,留给下次拼接
34
+  const rest = parts.pop() ?? ''
35
+
36
+  for (const part of parts) {
37
+    if (!part.trim()) continue
38
+    const lines = part.split('\n')
39
+    const dataLines = []
40
+    for (const line of lines) {
41
+      if (line.startsWith('data:')) {
42
+        dataLines.push(line.slice(5).trimStart())
43
+      }
44
+    }
45
+    const data = dataLines.join('\n')
46
+    if (data !== '') events.push(data)
47
+  }
32 48
 
33
-  const controller = new AbortController()
49
+  return { events, rest }
50
+}
51
+
52
+function normalizeSseData(data) {
53
+  if (!data) return ''
54
+  const trimmed = data.trim()
55
+  // 移除可能的JSON包装(如果有的话)
56
+  if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
57
+    try {
58
+      const parsed = JSON.parse(trimmed)
59
+      if (parsed.content) return parsed.content
60
+      if (parsed.resultContent) return parsed.resultContent
61
+    } catch (e) {
62
+      // 不是JSON,直接返回
63
+    }
64
+  }
65
+  return trimmed
66
+}
34 67
 
68
+function stripToolCallStream(text, state) {
69
+  if (!text) return ''
70
+  const s = String(text)
71
+  if (!state.inToolCall) {
72
+    const toolStart = s.indexOf('<tool_call>')
73
+    if (toolStart === -1) {
74
+      return s
75
+    } else {
76
+      state.inToolCall = true
77
+      return s.slice(0, toolStart)
78
+    }
79
+  } else {
80
+    const toolEnd = s.indexOf('</tool_call>')
81
+    if (toolEnd === -1) {
82
+      return ''
83
+    } else {
84
+      state.inToolCall = false
85
+      return s.slice(toolEnd + 11)
86
+    }
87
+  }
88
+}
89
+
90
+function streamFetchSse(url, onMessage, onError, onComplete) {
91
+  const controller = new AbortController()
92
+  const toolCallState = { inToolCall: false }
35 93
   fetch(url, {
36 94
     method: 'GET',
37 95
     headers: {
38 96
       'Authorization': 'Bearer ' + getToken(),
39
-      'Accept': 'application/json, text/event-stream',
97
+      'Accept': 'text/event-stream',
40 98
       'Cache-Control': 'no-cache'
41 99
     },
42 100
     signal: controller.signal
43
-  }).then(response => {
44
-    if (!response.ok) {
45
-      throw new Error(`HTTP error! status: ${response.status}`)
46
-    }
47
-
48
-    const reader = response.body.getReader()
49
-    const decoder = new TextDecoder()
50
-    let buffer = ''
51
-
52
-    function readStream() {
53
-      return reader.read().then(({ done, value }) => {
54
-        if (done) {
55
-          console.log('=== 流式读取完成 ===')
56
-          // 处理缓冲区中剩余的数据
57
-          if (buffer.trim()) {
58
-            console.log('=== 处理剩余缓冲区数据 ===', buffer)
59
-            const lines = buffer.split(/\r?\n/)
60
-            lines.forEach(line => {
61
-              line = line.trim()
62
-              if (!line || line.startsWith(':')) return
63
-
64
-              console.log('处理剩余数据行:', line)
65
-
66
-              // 尝试提取JSON数据
67
-              let jsonData = null
68
-
69
-              if (line.startsWith('data: ')) {
70
-                try {
71
-                  jsonData = JSON.parse(line.slice(6))
72
-                  console.log('解析的剩余SSE数据:', jsonData)
73
-                } catch (error) {
74
-                  console.error('解析剩余SSE数据失败:', error, line)
75
-                }
76
-              } else if (line.startsWith('data:')) {
77
-                try {
78
-                  jsonData = JSON.parse(line.slice(5))
79
-                  console.log('解析的剩余SSE数据(无空格):', jsonData)
80
-                } catch (error) {
81
-                  console.error('解析剩余SSE数据失败(无空格):', error, line)
82
-                }
83
-              } else {
84
-                try {
85
-                  jsonData = JSON.parse(line)
86
-                  console.log('解析的剩余JSON数据:', jsonData)
87
-                } catch (error) {
88
-                  console.error('解析剩余JSON数据失败:', error, line)
89
-                }
90
-              }
91
-
92
-              // 处理解析成功的数据
93
-              if (jsonData) {
94
-                console.log('=== 解析成功的剩余数据 ===', jsonData)
95
-
96
-                if (jsonData.resultContent) {
97
-                  console.log('=== 准备发送剩余resultContent ===', jsonData.resultContent)
98
-                  onMessage(jsonData.resultContent)
99
-                } else if (jsonData.choices && jsonData.choices[0] && jsonData.choices[0].delta && jsonData.choices[0].delta.content) {
100
-                  console.log('=== 准备发送剩余OpenAI格式内容 ===', jsonData.choices[0].delta.content)
101
-                  onMessage(jsonData.choices[0].delta.content)
102
-                } else if (typeof jsonData === 'string') {
103
-                  console.log('=== 准备发送剩余字符串 ===', jsonData)
104
-                  onMessage(jsonData)
105
-                } else {
106
-                  console.log('=== 剩余数据格式不匹配,跳过content字段 ===', jsonData)
107
-                }
108
-              }
109
-            })
101
+  })
102
+    .then(async (response) => {
103
+      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
104
+      if (!response.body) throw new Error('ReadableStream not supported')
105
+
106
+      const reader = response.body.getReader()
107
+      const decoder = new TextDecoder('utf-8')
108
+      let buffer = ''
109
+
110
+      while (true) {
111
+        const { done, value } = await reader.read()
112
+        if (done) break
113
+
114
+        buffer += decoder.decode(value, { stream: true })
115
+        const parsed = parseSseEvents(buffer)
116
+        buffer = parsed.rest
117
+
118
+        for (const data of parsed.events) {
119
+          const normalized = normalizeSseData(data)
120
+          if (normalized === '[DONE]') {
121
+            onComplete()
122
+            controller.abort()
123
+            return
110 124
           }
111
-
112
-          onComplete()
113
-          return
125
+          const visible = stripToolCallStream(normalized, toolCallState)
126
+          if (visible !== '') onMessage(visible)
114 127
         }
128
+      }
115 129
 
116
-        const chunk = decoder.decode(value, { stream: true })
117
-        console.log('接收到原始数据块:', chunk)
118
-        buffer += chunk
119
-
120
-        // 处理可能包含\r\n的情况
121
-        const lines = buffer.split(/\r?\n/)
122
-
123
-        // 保留最后一行,因为它可能不完整
124
-        buffer = lines.pop() || ''
125
-
126
-        console.log('处理的行数:', lines.length)
127
-        lines.forEach(line => {
128
-          line = line.trim()
129
-          if (!line || line.startsWith(':')) return
130
-
131
-          console.log('处理数据行:', line)
132
-
133
-          // 尝试提取JSON数据
134
-          let jsonData = null
135
-
136
-          if (line.startsWith('data: ')) {
137
-            try {
138
-              jsonData = JSON.parse(line.slice(6))
139
-              console.log('解析的SSE数据:', jsonData)
140
-            } catch (error) {
141
-              console.error('解析SSE数据失败:', error, line)
142
-            }
143
-          } else if (line.startsWith('data:')) {
144
-            try {
145
-              jsonData = JSON.parse(line.slice(5))
146
-              console.log('解析的SSE数据(无空格):', jsonData)
147
-            } catch (error) {
148
-              console.error('解析SSE数据失败(无空格):', error, line)
149
-            }
150
-          } else {
151
-            try {
152
-              jsonData = JSON.parse(line)
153
-              console.log('解析的JSON数据:', jsonData)
154
-            } catch (error) {
155
-              console.error('解析JSON数据失败:', error, line)
156
-            }
130
+      // 兜底:流结束但没收到 [DONE]
131
+      if (buffer.trim()) {
132
+        const parsed = parseSseEvents(buffer + '\n\n')
133
+        for (const data of parsed.events) {
134
+          const normalized = normalizeSseData(data)
135
+          if (normalized !== '' && normalized !== '[DONE]') {
136
+            const visible = stripToolCallStream(normalized, toolCallState)
137
+            if (visible !== '') onMessage(visible)
157 138
           }
158
-
159
-          // 处理解析成功的数据
160
-              if (jsonData) {
161
-                console.log('=== 解析成功的数据 ===', jsonData)
162
-
163
-                if (jsonData.resultContent) {
164
-                  console.log('=== 准备发送resultContent ===', jsonData.resultContent)
165
-                  onMessage(jsonData.resultContent)
166
-                } else if (jsonData.choices && jsonData.choices[0] && jsonData.choices[0].delta && jsonData.choices[0].delta.content) {
167
-                  console.log('=== 准备发送OpenAI格式内容 ===', jsonData.choices[0].delta.content)
168
-                  onMessage(jsonData.choices[0].delta.content)
169
-                } else if (typeof jsonData === 'string') {
170
-                  console.log('=== 准备发送字符串 ===', jsonData)
171
-                  onMessage(jsonData)
172
-                } else {
173
-                  console.log('=== 数据格式不匹配,跳过content字段 ===', jsonData)
174
-                }
175
-              }
176
-        })
177
-
178
-        return readStream()
179
-      })
180
-    }
181
-
182
-    return readStream()
183
-  })
184
-    .catch(error => {
185
-      if (error.name === 'AbortError') {
186
-        console.log('请求被取消')
187
-        return
139
+        }
188 140
       }
141
+      onComplete()
142
+    })
143
+    .catch((error) => {
144
+      if (error.name === 'AbortError') return
189 145
       console.error('流式请求错误:', error)
190
-      onError(new Error('网络连接失败,请检查网络连接后重试'))
146
+      onError(error)
191 147
     })
192 148
 
193
-  // 返回controller以便外部可以取消请求
194 149
   return controller
150
+}
151
+
152
+// 流式回答API(SSE)
153
+export function getAnswerStream(question, collectionName, onMessage, onError, onComplete) {
154
+  const baseURL = process.env.VUE_APP_BASE_API
155
+  const url = `${baseURL}/llm/rag/answer?question=${encodeURIComponent(question)}&collectionName=${encodeURIComponent(collectionName)}`
156
+  return streamFetchSse(url, onMessage, onError, onComplete)
195 157
 }

+ 3
- 2
oa-ui/src/views/llm/chat/index.vue Visa fil

@@ -171,8 +171,8 @@
171 171
               <div v-if="!currentTopicId && chatMessages.length === 0" class="welcome-message">
172 172
                 <div class="welcome-content">
173 173
                   <div class="welcome-icon">🤖</div>
174
-                  <h2>欢迎使用 AI 助手</h2>
175
-                  <p>我是您的智能助手,可以帮您解答问题、编写代码、分析数据等。</p>
174
+                  <h2>欢迎使用 OA 助手</h2>
175
+                  <p>我是您的智能助手,可以帮您解答问题、分析数据等。</p>
176 176
                   <p>请开始您的对话吧!</p>
177 177
                 </div>
178 178
               </div>
@@ -405,6 +405,7 @@ export default {
405 405
   },
406 406
 
407 407
   mounted() {
408
+    this.fileInput = this.$refs.fileInput;
408 409
     this.getList();
409 410
   },
410 411
 

+ 114
- 71
oa-ui/src/views/llm/knowledge/index.vue Visa fil

@@ -197,21 +197,17 @@
197 197
                 </div>
198 198
               </div>
199 199
 
200
-              <!-- AI回答loading状态 -->
201
-              <div
202
-                v-if="isSending && chatMessages.length > 0 && chatMessages[chatMessages.length - 1].type === 'ai' && chatMessages[chatMessages.length - 1].content === ''"
203
-                class="message-item ai">
200
+              <!-- 加载状态:仅在尚未收到首段流内容时显示,避免出现两个AI气泡 -->
201
+              <div v-if="isSending && !streamingStarted" class="message-item ai">
204 202
                 <div class="message-avatar">
205 203
                   <div class="ai-avatar">
206
-                    <i class="el-icon-chat-dot-round"></i>
204
+                    🤖
207 205
                   </div>
208 206
                 </div>
209 207
                 <div class="message-content">
210 208
                   <div class="message-bubble ai">
211 209
                     <div class="loading-dots">
212
-                      <span></span>
213
-                      <span></span>
214
-                      <span></span>
210
+                      <span></span><span></span><span></span>
215 211
                     </div>
216 212
                   </div>
217 213
                 </div>
@@ -308,6 +304,68 @@ import { Message } from 'element-ui'
308 304
 import { getToken } from "@/utils/auth";
309 305
 import { listKnowledge, listKnowLedgeByCollectionName, addKnowledge, updateKnowledge, delKnowledge, insertKnowledgeFile, listKnowledgeDocument, deleteKnowledgeFile, getProcessValue } from "@/api/llm/knowLedge";
310 306
 import { getAnswer, getAnswerStream, getContextFile } from '@/api/llm/rag';
307
+import { marked } from 'marked';
308
+
309
+function createTypewriter(appender, options = {}) {
310
+  const intervalMs = typeof options.intervalMs === 'number' ? options.intervalMs : 25 // 约 40 字/秒
311
+  const maxCharsPerTick = typeof options.maxCharsPerTick === 'number' ? options.maxCharsPerTick : 1
312
+
313
+  let queue = ''
314
+  let timer = null
315
+  let ended = false
316
+  let onDrained = null
317
+
318
+  const tick = () => {
319
+    if (!queue) {
320
+      if (ended) {
321
+        if (timer) clearInterval(timer)
322
+        timer = null
323
+        if (onDrained) onDrained()
324
+      }
325
+      return
326
+    }
327
+
328
+    const n = Math.min(maxCharsPerTick, queue.length)
329
+    const chunk = queue.slice(0, n)
330
+    queue = queue.slice(n)
331
+    appender(chunk)
332
+  }
333
+
334
+  return {
335
+    push(text) {
336
+      if (!text) return
337
+      queue += text
338
+      if (!timer) timer = setInterval(tick, intervalMs)
339
+    },
340
+    end(cb) {
341
+      ended = true
342
+      onDrained = cb
343
+      if (!timer) timer = setInterval(tick, intervalMs)
344
+    },
345
+    stop() {
346
+      ended = true
347
+      queue = ''
348
+      if (timer) clearInterval(timer)
349
+      timer = null
350
+    }
351
+  }
352
+}
353
+
354
+function trimToLastSentenceEnd(text) {
355
+  const s = String(text || '')
356
+  // 句末标点:中文/英文句号、问号、叹号,或换行
357
+  const last = Math.max(
358
+    s.lastIndexOf('。'),
359
+    s.lastIndexOf('!'),
360
+    s.lastIndexOf('?'),
361
+    s.lastIndexOf('.'),
362
+    s.lastIndexOf('!'),
363
+    s.lastIndexOf('?'),
364
+    s.lastIndexOf('\n')
365
+  )
366
+  if (last === -1) return s.trim()
367
+  return s.slice(0, last + 1).trim()
368
+}
311 369
 
312 370
 export default {
313 371
   name: 'KnowledgeManager',
@@ -342,6 +400,7 @@ export default {
342 400
       chatMessages: [],
343 401
       chatInput: '',
344 402
       isSending: false,
403
+      streamingStarted: false,
345 404
       chatMessagesRef: null,
346 405
 
347 406
       // 表单数据
@@ -476,6 +535,7 @@ export default {
476 535
       this.isChatMode = false;
477 536
       this.chatMessages = [];
478 537
       this.chatInput = '';
538
+      this.streamingStarted = false;
479 539
 
480 540
       // 重置文件分页状态
481 541
       this.filePageNum = 1;
@@ -506,6 +566,7 @@ export default {
506 566
       this.isChatMode = true;
507 567
       this.chatMessages = [];
508 568
       this.chatInput = '';
569
+      this.streamingStarted = false;
509 570
       // 滚动到底部
510 571
       this.$nextTick(() => {
511 572
         this.scrollToBottom();
@@ -541,6 +602,7 @@ export default {
541 602
       const currentInput = this.chatInput;
542 603
       this.chatInput = '';
543 604
       this.isSending = true;
605
+      this.streamingStarted = false;
544 606
 
545 607
       // 滚动到底部
546 608
       this.$nextTick(() => {
@@ -557,6 +619,15 @@ export default {
557 619
       };
558 620
       this.chatMessages.push(aiMessage);
559 621
 
622
+      const typewriter = createTypewriter((chunk) => {
623
+        aiMessage.content += chunk
624
+        aiMessage.time = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
625
+        this.$nextTick(() => this.scrollToBottom())
626
+      }, {
627
+        intervalMs: 25,
628
+        maxCharsPerTick: 1
629
+      })
630
+
560 631
       // 使用流式API获取回答
561 632
       const eventSource = getAnswerStream(
562 633
         currentInput,
@@ -564,52 +635,35 @@ export default {
564 635
         // onMessage: 接收到每个字符时的回调
565 636
         (content) => {
566 637
           const that = this;
567
-          // 处理接收到的内容
568
-          console.log('=== 前端接收到内容 ===', content)
569
-
570
-          // 清理内容中的</think>标签
571
-          let cleanContent = content.replace(/<\/?think>/g, '');
638
+          if (!that.streamingStarted) that.streamingStarted = true;
572 639
 
573
-          // 如果内容为空或只包含空白字符,跳过
574
-          if (!cleanContent.trim()) {
640
+          if (!content || !String(content).trim()) {
575 641
             return;
576 642
           }
577 643
 
578
-          // 直接替换内容,避免重复叠加
579
-          aiMessage.content += cleanContent;
580
-          aiMessage.time = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}');
581
-          console.log('=== 清理后的内容 ===', cleanContent)
582
-          console.log('=== 当前AI消息完整内容 ===', aiMessage.content)
644
+          typewriter.push(String(content))
583 645
 
584
-          // 每次接收到消息都重置超时定时器
585 646
           if (window.responseTimeout) {
586 647
             clearTimeout(window.responseTimeout);
587 648
           }
588
-
589
-          // 重新设置超时定时器(5分钟无新消息才超时)
590 649
           window.responseTimeout = setTimeout(() => {
591 650
             if (that.isSending) {
592
-              console.log('=== 响应超时强制结束 ===')
651
+              console.log('=== 响应超时强制结束 ===');
593 652
               that.isSending = false;
594
-              if (window.currentController) {
595
-                window.currentController.abort();
596
-                window.currentController = null;
653
+              typewriter.stop()
654
+              if (window.responseTimeout) {
655
+                clearTimeout(window.responseTimeout);
656
+                window.responseTimeout = null;
597 657
               }
598
-              window.responseTimeout = null;
599 658
             }
600
-          }, 300000); // 30秒无响应超时
659
+          }, 300000);
601 660
 
602
-          // 滚动到底部
603
-          that.$nextTick(() => {
604
-            that.scrollToBottom();
605
-          });
606 661
         },
607 662
         // onError: 发生错误时的回调
608 663
         (error) => {
609 664
           const that = this;
610 665
           console.error('=== 流式回答错误 ===', error);
611 666
 
612
-          // 清除超时定时器
613 667
           if (window.responseTimeout) {
614 668
             clearTimeout(window.responseTimeout);
615 669
             window.responseTimeout = null;
@@ -621,14 +675,8 @@ export default {
621 675
             aiMessage.content += '\n\n[回答生成中断]';
622 676
           }
623 677
           that.isSending = false;
624
-          console.log('=== 错误时isSending设置为false ===')
678
+          typewriter.stop()
625 679
 
626
-          // 清理控制器
627
-          if (window.currentController) {
628
-            window.currentController = null;
629
-          }
630
-
631
-          // 滚动到底部
632 680
           that.$nextTick(() => {
633 681
             that.scrollToBottom();
634 682
           });
@@ -636,43 +684,38 @@ export default {
636 684
         // onComplete: 回答完成时的回调
637 685
         () => {
638 686
           const that = this;
639
-          console.log('=== 回答完成,正确进入onComplete ===')
687
+          console.log('=== 回答完成 ===');
640 688
 
641
-          // 清除超时定时器
642 689
           if (window.responseTimeout) {
643 690
             clearTimeout(window.responseTimeout);
644 691
             window.responseTimeout = null;
645 692
           }
646 693
 
647
-          that.isSending = false;
648
-          console.log('=== onComplete中isSending设置为false ===')
649
-
650
-          // 清理控制器
651
-          if (window.currentController) {
652
-            window.currentController = null;
653
-          }
694
+          typewriter.end(async () => {
695
+            aiMessage.content = trimToLastSentenceEnd(aiMessage.content)
696
+            try {
697
+              // 获取上下文引用文件
698
+              const response = await getContextFile(currentInput, that.selectedKnowledge.collectionName);
699
+              console.log('=== 上下文文件 ===', response)
700
+              if (response && Array.isArray(response)) {
701
+                aiMessage.references = response.map(item => ({
702
+                  fileName: item.file_name,
703
+                  similarity: item.score,
704
+                  content: item.content
705
+                }));
706
+                that.$forceUpdate(); // 强制更新以显示引用文件
707
+              }
708
+            } catch (error) {
709
+              console.error('获取上下文文件失败:', error);
710
+            }
654 711
 
655
-          // 确保状态更新
656
-          that.$nextTick(() => {
657
-            console.log('=== 最终isSending状态 ===', that.isSending)
658
-            that.scrollToBottom();
659
-          });
712
+            that.isSending = false;
713
+            that.$nextTick(() => {
714
+              that.scrollToBottom();
715
+            });
716
+          })
660 717
         }
661 718
       );
662
-      // 获取上下文引用文件
663
-      getContextFile(currentInput, this.selectedKnowledge.collectionName).then(response => {
664
-        console.log('=== 上下文文件 ===', response)
665
-        if (response && Array.isArray(response)) {
666
-          aiMessage.references = response.map(item => ({
667
-            fileName: item.file_name,
668
-            similarity: item.score,
669
-            content: item.content
670
-          }));
671
-          this.$forceUpdate(); // 强制更新以显示引用文件
672
-        }
673
-      }).catch(error => {
674
-        console.error('获取上下文文件失败:', error);
675
-      });
676 719
       // 如果用户快速发送多条消息,取消之前的请求
677 720
       if (window.currentController) {
678 721
         window.currentController.abort();
@@ -740,8 +783,8 @@ export default {
740 783
 
741 784
     /** 格式化消息内容 */
742 785
     formatMessage(content) {
743
-      // 简单的换行处理
744
-      return content.replace(/\n/g, '<br>');
786
+      // 直接使用marked解析markdown内容
787
+      return marked(content);
745 788
     },
746 789
 
747 790
     /** 格式化相似度 */

Loading…
Avbryt
Spara