Просмотр исходного кода

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

lamphua 1 неделю назад
Родитель
Сommit
3869b12412

+ 1
- 1
oa-back/ruoyi-agent/pom.xml Просмотреть файл

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

+ 3
- 3
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/RagController.java Просмотреть файл

3
 import com.alibaba.fastjson2.JSONObject;
3
 import com.alibaba.fastjson2.JSONObject;
4
 import com.ruoyi.web.llm.service.ILangChainMilvusService;
4
 import com.ruoyi.web.llm.service.ILangChainMilvusService;
5
 import com.ruoyi.common.core.controller.BaseController;
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
 import org.springframework.beans.factory.annotation.Autowired;
7
 import org.springframework.beans.factory.annotation.Autowired;
8
 import org.springframework.web.bind.annotation.GetMapping;
8
 import org.springframework.web.bind.annotation.GetMapping;
9
 import org.springframework.web.bind.annotation.RequestMapping;
9
 import org.springframework.web.bind.annotation.RequestMapping;
29
     /**
29
     /**
30
      * 调用LLM+RAG(知识库)生成回答
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
         List<JSONObject> contexts = langChainMilvusService.retrieveFromMilvus(collectionName, question, 10);
34
         List<JSONObject> contexts = langChainMilvusService.retrieveFromMilvus(collectionName, question, 10);
35
         return langChainMilvusService.generateAnswerWithCollection(topicId, question, contexts);
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 Просмотреть файл

2
 
2
 
3
 import com.ruoyi.common.core.controller.BaseController;
3
 import com.ruoyi.common.core.controller.BaseController;
4
 import com.ruoyi.web.llm.service.ISessionService;
4
 import com.ruoyi.web.llm.service.ISessionService;
5
-import org.noear.solon.ai.chat.message.AssistantMessage;
6
 import org.noear.solon.core.util.MimeType;
5
 import org.noear.solon.core.util.MimeType;
7
 import org.springframework.beans.factory.annotation.Autowired;
6
 import org.springframework.beans.factory.annotation.Autowired;
8
 import org.springframework.web.bind.annotation.GetMapping;
7
 import org.springframework.web.bind.annotation.GetMapping;
35
      * 调用LLM+RAG(外部文件)生成回答
34
      * 调用LLM+RAG(外部文件)生成回答
36
      */
35
      */
37
     @GetMapping(value = "/answerWithDocument", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
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
         return sessionService.answerWithDocument(topicId, chatId, question);
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 Просмотреть файл

1
 package com.ruoyi.web.llm.service;
1
 package com.ruoyi.web.llm.service;
2
 
2
 
3
 import com.alibaba.fastjson2.JSONObject;
3
 import com.alibaba.fastjson2.JSONObject;
4
-import org.noear.solon.ai.chat.message.AssistantMessage;
5
 import org.springframework.web.multipart.MultipartFile;
4
 import org.springframework.web.multipart.MultipartFile;
6
 import reactor.core.publisher.Flux;
5
 import reactor.core.publisher.Flux;
7
 
6
 
39
      * 调用LLM生成回答
38
      * 调用LLM生成回答
40
      * @return
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
      * 调用LLM+RAG(知识库)生成回答
44
      * 调用LLM+RAG(知识库)生成回答
46
      * @return
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
      * 调用LLM+RAG(外部文件)生成回答
50
      * 调用LLM+RAG(外部文件)生成回答
52
      * @return
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
      * 调用LLM+RAG(外部文件+知识库)生成回答
56
      * 调用LLM+RAG(外部文件+知识库)生成回答
58
      * @return
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 Просмотреть файл

1
 package com.ruoyi.web.llm.service;
1
 package com.ruoyi.web.llm.service;
2
 
2
 
3
-import org.noear.solon.ai.chat.message.AssistantMessage;
4
 import reactor.core.publisher.Flux;
3
 import reactor.core.publisher.Flux;
5
 
4
 
6
 public interface ISessionService {
5
 public interface ISessionService {
13
     /**
12
     /**
14
      * 调用LLM+RAG(外部文件)生成回答
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 Просмотреть файл

40
 import org.noear.solon.ai.chat.ChatModel;
40
 import org.noear.solon.ai.chat.ChatModel;
41
 import org.noear.solon.ai.chat.ChatResponse;
41
 import org.noear.solon.ai.chat.ChatResponse;
42
 import org.noear.solon.ai.chat.ChatSession;
42
 import org.noear.solon.ai.chat.ChatSession;
43
-import org.noear.solon.ai.chat.message.AssistantMessage;
44
 import org.noear.solon.ai.chat.message.ChatMessage;
43
 import org.noear.solon.ai.chat.message.ChatMessage;
44
+import org.noear.solon.ai.chat.prompt.Prompt;
45
 import org.noear.solon.ai.chat.session.InMemoryChatSession;
45
 import org.noear.solon.ai.chat.session.InMemoryChatSession;
46
 import org.reactivestreams.Publisher;
46
 import org.reactivestreams.Publisher;
47
 import org.springframework.beans.factory.annotation.Autowired;
47
 import org.springframework.beans.factory.annotation.Autowired;
84
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
84
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
85
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
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
     @PostConstruct
93
     @PostConstruct
267
      * @return
267
      * @return
268
      */
268
      */
269
     @Override
269
     @Override
270
-    public Flux<AssistantMessage> generateAnswer(String topicId, String prompt) {
270
+    public Flux<String> generateAnswer(String topicId, String prompt) {
271
         List<ChatMessage> messages = new ArrayList<>();
271
         List<ChatMessage> messages = new ArrayList<>();
272
         if (topicId != null) {
272
         if (topicId != null) {
273
             CmcChat cmcChat = new CmcChat();
273
             CmcChat cmcChat = new CmcChat();
280
         }
280
         }
281
         messages.add(ChatMessage.ofUser(prompt));
281
         messages.add(ChatMessage.ofUser(prompt));
282
         ChatSession chatSession =  InMemoryChatSession.builder().messages(messages).build();
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
         return Flux.from(publisher)
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
      * @return
317
      * @return
291
      */
318
      */
292
     @Override
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
         StringBuilder sb = new StringBuilder();
321
         StringBuilder sb = new StringBuilder();
295
         sb.append("问题: ").append(question).append("\n\n");
322
         sb.append("问题: ").append(question).append("\n\n");
296
         sb.append("根据以下上下文回答问题:\n\n");
323
         sb.append("根据以下上下文回答问题:\n\n");
307
      * 调用LLM生成回答
334
      * 调用LLM生成回答
308
      */
335
      */
309
     @Override
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
         CmcDocument cmcDocument = new CmcDocument();
338
         CmcDocument cmcDocument = new CmcDocument();
312
         cmcDocument.setChatId(chatId);
339
         cmcDocument.setChatId(chatId);
313
         List<CmcDocument> documentList = cmcDocumentService.selectCmcDocumentList(cmcDocument);
340
         List<CmcDocument> documentList = cmcDocumentService.selectCmcDocumentList(cmcDocument);
340
      * 调用LLM生成回答
367
      * 调用LLM生成回答
341
      */
368
      */
342
     @Override
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
         StringBuilder sb = new StringBuilder("招标文件内容:\n\n");
371
         StringBuilder sb = new StringBuilder("招标文件内容:\n\n");
345
         CmcChat cmcChat = new CmcChat();
372
         CmcChat cmcChat = new CmcChat();
346
         cmcChat.setTopicId(topicId);
373
         cmcChat.setTopicId(topicId);

+ 4
- 4
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/MilvusServiceImpl.java Просмотреть файл

35
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
35
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
36
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
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
     @PreDestroy
44
     @PreDestroy

+ 5
- 3
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/SessionServiceImpl.java Просмотреть файл

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

+ 111
- 149
oa-ui/src/api/llm/rag.js Просмотреть файл

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
   fetch(url, {
93
   fetch(url, {
36
     method: 'GET',
94
     method: 'GET',
37
     headers: {
95
     headers: {
38
       'Authorization': 'Bearer ' + getToken(),
96
       'Authorization': 'Bearer ' + getToken(),
39
-      'Accept': 'application/json, text/event-stream',
97
+      'Accept': 'text/event-stream',
40
       'Cache-Control': 'no-cache'
98
       'Cache-Control': 'no-cache'
41
     },
99
     },
42
     signal: controller.signal
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
       console.error('流式请求错误:', error)
145
       console.error('流式请求错误:', error)
190
-      onError(new Error('网络连接失败,请检查网络连接后重试'))
146
+      onError(error)
191
     })
147
     })
192
 
148
 
193
-  // 返回controller以便外部可以取消请求
194
   return controller
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 Просмотреть файл

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

+ 114
- 71
oa-ui/src/views/llm/knowledge/index.vue Просмотреть файл

197
                 </div>
197
                 </div>
198
               </div>
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
                 <div class="message-avatar">
202
                 <div class="message-avatar">
205
                   <div class="ai-avatar">
203
                   <div class="ai-avatar">
206
-                    <i class="el-icon-chat-dot-round"></i>
204
+                    🤖
207
                   </div>
205
                   </div>
208
                 </div>
206
                 </div>
209
                 <div class="message-content">
207
                 <div class="message-content">
210
                   <div class="message-bubble ai">
208
                   <div class="message-bubble ai">
211
                     <div class="loading-dots">
209
                     <div class="loading-dots">
212
-                      <span></span>
213
-                      <span></span>
214
-                      <span></span>
210
+                      <span></span><span></span><span></span>
215
                     </div>
211
                     </div>
216
                   </div>
212
                   </div>
217
                 </div>
213
                 </div>
308
 import { getToken } from "@/utils/auth";
304
 import { getToken } from "@/utils/auth";
309
 import { listKnowledge, listKnowLedgeByCollectionName, addKnowledge, updateKnowledge, delKnowledge, insertKnowledgeFile, listKnowledgeDocument, deleteKnowledgeFile, getProcessValue } from "@/api/llm/knowLedge";
305
 import { listKnowledge, listKnowLedgeByCollectionName, addKnowledge, updateKnowledge, delKnowledge, insertKnowledgeFile, listKnowledgeDocument, deleteKnowledgeFile, getProcessValue } from "@/api/llm/knowLedge";
310
 import { getAnswer, getAnswerStream, getContextFile } from '@/api/llm/rag';
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
 export default {
370
 export default {
313
   name: 'KnowledgeManager',
371
   name: 'KnowledgeManager',
342
       chatMessages: [],
400
       chatMessages: [],
343
       chatInput: '',
401
       chatInput: '',
344
       isSending: false,
402
       isSending: false,
403
+      streamingStarted: false,
345
       chatMessagesRef: null,
404
       chatMessagesRef: null,
346
 
405
 
347
       // 表单数据
406
       // 表单数据
476
       this.isChatMode = false;
535
       this.isChatMode = false;
477
       this.chatMessages = [];
536
       this.chatMessages = [];
478
       this.chatInput = '';
537
       this.chatInput = '';
538
+      this.streamingStarted = false;
479
 
539
 
480
       // 重置文件分页状态
540
       // 重置文件分页状态
481
       this.filePageNum = 1;
541
       this.filePageNum = 1;
506
       this.isChatMode = true;
566
       this.isChatMode = true;
507
       this.chatMessages = [];
567
       this.chatMessages = [];
508
       this.chatInput = '';
568
       this.chatInput = '';
569
+      this.streamingStarted = false;
509
       // 滚动到底部
570
       // 滚动到底部
510
       this.$nextTick(() => {
571
       this.$nextTick(() => {
511
         this.scrollToBottom();
572
         this.scrollToBottom();
541
       const currentInput = this.chatInput;
602
       const currentInput = this.chatInput;
542
       this.chatInput = '';
603
       this.chatInput = '';
543
       this.isSending = true;
604
       this.isSending = true;
605
+      this.streamingStarted = false;
544
 
606
 
545
       // 滚动到底部
607
       // 滚动到底部
546
       this.$nextTick(() => {
608
       this.$nextTick(() => {
557
       };
619
       };
558
       this.chatMessages.push(aiMessage);
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
       // 使用流式API获取回答
631
       // 使用流式API获取回答
561
       const eventSource = getAnswerStream(
632
       const eventSource = getAnswerStream(
562
         currentInput,
633
         currentInput,
564
         // onMessage: 接收到每个字符时的回调
635
         // onMessage: 接收到每个字符时的回调
565
         (content) => {
636
         (content) => {
566
           const that = this;
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
             return;
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
           if (window.responseTimeout) {
646
           if (window.responseTimeout) {
586
             clearTimeout(window.responseTimeout);
647
             clearTimeout(window.responseTimeout);
587
           }
648
           }
588
-
589
-          // 重新设置超时定时器(5分钟无新消息才超时)
590
           window.responseTimeout = setTimeout(() => {
649
           window.responseTimeout = setTimeout(() => {
591
             if (that.isSending) {
650
             if (that.isSending) {
592
-              console.log('=== 响应超时强制结束 ===')
651
+              console.log('=== 响应超时强制结束 ===');
593
               that.isSending = false;
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
         // onError: 发生错误时的回调
662
         // onError: 发生错误时的回调
608
         (error) => {
663
         (error) => {
609
           const that = this;
664
           const that = this;
610
           console.error('=== 流式回答错误 ===', error);
665
           console.error('=== 流式回答错误 ===', error);
611
 
666
 
612
-          // 清除超时定时器
613
           if (window.responseTimeout) {
667
           if (window.responseTimeout) {
614
             clearTimeout(window.responseTimeout);
668
             clearTimeout(window.responseTimeout);
615
             window.responseTimeout = null;
669
             window.responseTimeout = null;
621
             aiMessage.content += '\n\n[回答生成中断]';
675
             aiMessage.content += '\n\n[回答生成中断]';
622
           }
676
           }
623
           that.isSending = false;
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
           that.$nextTick(() => {
680
           that.$nextTick(() => {
633
             that.scrollToBottom();
681
             that.scrollToBottom();
634
           });
682
           });
636
         // onComplete: 回答完成时的回调
684
         // onComplete: 回答完成时的回调
637
         () => {
685
         () => {
638
           const that = this;
686
           const that = this;
639
-          console.log('=== 回答完成,正确进入onComplete ===')
687
+          console.log('=== 回答完成 ===');
640
 
688
 
641
-          // 清除超时定时器
642
           if (window.responseTimeout) {
689
           if (window.responseTimeout) {
643
             clearTimeout(window.responseTimeout);
690
             clearTimeout(window.responseTimeout);
644
             window.responseTimeout = null;
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
       if (window.currentController) {
720
       if (window.currentController) {
678
         window.currentController.abort();
721
         window.currentController.abort();
740
 
783
 
741
     /** 格式化消息内容 */
784
     /** 格式化消息内容 */
742
     formatMessage(content) {
785
     formatMessage(content) {
743
-      // 简单的换行处理
744
-      return content.replace(/\n/g, '<br>');
786
+      // 直接使用marked解析markdown内容
787
+      return marked(content);
745
     },
788
     },
746
 
789
 
747
     /** 格式化相似度 */
790
     /** 格式化相似度 */

Загрузка…
Отмена
Сохранить