lamphua 14 часов назад
Родитель
Сommit
4240544bc2
37 измененных файлов: 4095 добавлений и 2701 удалений
  1. 4
    2
      oa-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/oa/CmcContractController.java
  2. 6
    0
      oa-back/ruoyi-common/pom.xml
  3. 7
    7
      oa-back/ruoyi-common/src/main/java/com/ruoyi/common/utils/milvus/MilvusConnectionPool.java
  4. 13
    0
      oa-back/ruoyi-flowable/src/main/java/com/ruoyi/flowable/agent/MyServiceTask.java
  5. 23
    2
      oa-back/ruoyi-flowable/src/main/java/com/ruoyi/flowable/service/impl/FlowTaskServiceImpl.java
  6. 0
    49
      oa-back/ruoyi-llm/pom.xml
  7. 0
    1
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/CmcAgentController.java
  8. 0
    1
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/CmcChatController.java
  9. 0
    1
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/CmcDocumentController.java
  10. 0
    1
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/CmcTopicController.java
  11. 2
    2
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/KnowLedgeController.java
  12. 0
    100
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/McpController.java
  13. 4
    6
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/RagController.java
  14. 6
    8
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/SessionController.java
  15. 0
    17
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/ISessionService.java
  16. 0
    782
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/LangChainMilvusServiceImpl.java
  17. 0
    379
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/MilvusServiceImpl.java
  18. 0
    274
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/SessionServiceImpl.java
  19. 123
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/domain/AnswerStreamResponse.java
  20. 47
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/domain/ChapterStreamResponse.java
  21. 6
    6
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/ILangChainMilvusService.java
  22. 1
    1
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/IMilvusService.java
  23. 17
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/ISessionService.java
  24. 588
    386
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/CmcAgentServiceImpl.java
  25. 1258
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/LangChainMilvusServiceImpl.java
  26. 470
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/MilvusServiceImpl.java
  27. 578
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/SessionServiceImpl.java
  28. 3
    3
      oa-back/ruoyi-system/src/main/java/com/ruoyi/oa/domain/CmcTransfer.java
  29. 30
    28
      oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcContractMapper.xml
  30. 27
    24
      oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcSubContractMapper.xml
  31. 5
    3
      oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcWageMapper.xml
  32. 104
    53
      oa-ui/src/api/llm/rag.js
  33. 113
    45
      oa-ui/src/api/llm/session.js
  34. 273
    95
      oa-ui/src/views/llm/agent/AgentDetail.vue
  35. 1
    1
      oa-ui/src/views/llm/agent/index.vue
  36. 243
    303
      oa-ui/src/views/llm/chat/index.vue
  37. 143
    121
      oa-ui/src/views/llm/knowledge/index.vue

+ 4
- 2
oa-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/oa/CmcContractController.java Просмотреть файл

@@ -104,8 +104,10 @@ public class CmcContractController extends BaseController
104 104
         if (cmcContract.getSignDate() == null) {
105 105
             if (cmcContract.getParams().size() == 0) {
106 106
                 for (int i = 2019; i <= Calendar.getInstance().get(Calendar.YEAR); i++) {
107
-                    cmcContract.setSignDate(new SimpleDateFormat("yyyy").parse(String.valueOf(i)));
107
+                    cmcContract.setContractCode("HT" + String.valueOf(i));
108 108
                     List<CmcContract> yearContractList = cmcContractService.selectCmcContractList(cmcContract);
109
+                    cmcContract.setContractCode("-" + (i - 2000));
110
+                    yearContractList.addAll(cmcContractService.selectCmcContractList(cmcContract));
109 111
                     int yearCountI = yearContractList.size();
110 112
                     int paidYearCountI = 0;
111 113
                     BigDecimal yearAmountI = new BigDecimal(0);
@@ -186,7 +188,7 @@ public class CmcContractController extends BaseController
186 188
                 params.put("beginTime", beginTimeString + " 00:00:00");
187 189
                 params.put("endTime", endTimeString + " 23:59:59");
188 190
                 cmcContract.setParams(params);
189
-                List<CmcContract> monthContractList = cmcContractService.selectCmcContractListByRange(cmcContract);
191
+                List<CmcContract> monthContractList = cmcContractService.selectCmcContractListByRange(cmcContract); 
190 192
                 int monthCount = monthContractList.size();
191 193
                 int paidMonthCount = 0;
192 194
                 BigDecimal monthAmount = new BigDecimal(0);

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

@@ -131,6 +131,12 @@
131 131
             <artifactId>pinyin4j</artifactId>
132 132
             <version>2.5.0</version>
133 133
         </dependency>
134
+
135
+        <dependency>
136
+            <groupId>io.milvus</groupId>
137
+            <artifactId>milvus-sdk-java</artifactId>
138
+            <version>2.6.2</version>
139
+        </dependency>
134 140
     </dependencies>
135 141
 
136 142
 </project>

oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/MilvusConnectionPool.java → oa-back/ruoyi-common/src/main/java/com/ruoyi/common/utils/milvus/MilvusConnectionPool.java Просмотреть файл

@@ -1,4 +1,4 @@
1
-package com.ruoyi.llm.service;
1
+package com.ruoyi.common.utils.milvus;
2 2
 
3 3
 import io.milvus.v2.client.ConnectConfig;
4 4
 import io.milvus.v2.client.MilvusClientV2;
@@ -36,12 +36,12 @@ public class MilvusConnectionPool {
36 36
         }
37 37
 
38 38
         for (int i = 0; i < POOL_SIZE; i++) {
39
-            MilvusClientV2 client = new MilvusClientV2(
40
-                    ConnectConfig.builder()
41
-                            .uri(milvusServiceUrl)
42
-                            .build());
43
-            clientPool.add(client);
44
-            availableClients.offer(client);
39
+            // MilvusClientV2 client = new MilvusClientV2(
40
+            //         ConnectConfig.builder()
41
+            //                 .uri(milvusServiceUrl)
42
+            //                 .build());
43
+            // clientPool.add(client);
44
+            // availableClients.offer(client);
45 45
         }
46 46
         initialized = true;
47 47
         System.out.println("Milvus连接池初始化完成,连接数: " + POOL_SIZE);

+ 13
- 0
oa-back/ruoyi-flowable/src/main/java/com/ruoyi/flowable/agent/MyServiceTask.java Просмотреть файл

@@ -0,0 +1,13 @@
1
+package com.ruoyi.flowable.agent;
2
+
3
+import org.flowable.engine.delegate.DelegateExecution;
4
+import org.flowable.engine.delegate.JavaDelegate;
5
+import org.springframework.stereotype.Component;
6
+
7
+@Component("MyServiceTask")
8
+public class MyServiceTask implements JavaDelegate {
9
+    @Override
10
+    public void execute(DelegateExecution execution) {
11
+        System.out.println("========MyServiceTask==========");
12
+    }
13
+}

+ 23
- 2
oa-back/ruoyi-flowable/src/main/java/com/ruoyi/flowable/service/impl/FlowTaskServiceImpl.java Просмотреть файл

@@ -137,6 +137,12 @@ public class FlowTaskServiceImpl extends FlowServiceFactory implements IFlowTask
137 137
     @Resource
138 138
     private ICmcTrainApprovalService cmcTrainApprovalService;
139 139
     @Resource
140
+    private ICmcTitleEvalService cmcTitleEvalService;
141
+    @Resource
142
+    private ICmcTransferService cmcTransferService;
143
+    @Resource
144
+    private ICmcRecruitService cmcRecruitService;
145
+    @Resource
140 146
     private ICmcBudgetService cmcBudgetService;
141 147
     @Resource
142 148
     private ICmcCheckService cmcCheckService;
@@ -1823,12 +1829,12 @@ public class FlowTaskServiceImpl extends FlowServiceFactory implements IFlowTask
1823 1829
         if (flowTaskDto.getProcDefName().contains("承接")) {
1824 1830
             CmcContract cmcContract = cmcContractService.selectCmcContractByContractId(formId);
1825 1831
             if (cmcContract != null)
1826
-                flowTaskDto.setTitle(cmcContractService.selectCmcContractByContractId(formId).getContractName());
1832
+                flowTaskDto.setTitle(cmcContract.getContractName());
1827 1833
         }
1828 1834
         if (flowTaskDto.getProcDefName().contains("分包")) {
1829 1835
             CmcSubContract cmcSubContract = cmcSubContractService.selectCmcSubContractBySubContractId(formId);
1830 1836
             if (cmcSubContract != null)
1831
-                flowTaskDto.setTitle(cmcSubContractService.selectCmcSubContractBySubContractId(formId).getSubContractName());
1837
+                flowTaskDto.setTitle(cmcSubContract.getSubContractName());
1832 1838
         }
1833 1839
         if (flowTaskDto.getProcDefName().equals("采购审批")) {
1834 1840
             CmcProcureApproval cmcProcureApproval = cmcProcureApprovalService.selectCmcProcureApprovalByProcureApplyId(formId);
@@ -1845,5 +1851,20 @@ public class FlowTaskServiceImpl extends FlowServiceFactory implements IFlowTask
1845 1851
             if (cmcTrainApproval != null)
1846 1852
                 flowTaskDto.setTitle(cmcTrainApproval.getContent());
1847 1853
         }
1854
+        if (flowTaskDto.getProcDefName().equals("职称评审")) {
1855
+            CmcTitleEval cmcTitleEval = cmcTitleEvalService.selectCmcTitleEvalByTitleEvalId(formId);
1856
+            if (cmcTitleEval != null)
1857
+                flowTaskDto.setTitle(cmcTitleEval.getAnnual() + cmcTitleEval.getInstitude() + "-" + cmcTitleEval.getTitleProfession() + cmcTitleEval.getLevel());
1858
+        }
1859
+        if (flowTaskDto.getProcDefName().equals("调岗审批")) {
1860
+            CmcTransfer cmcTransfer = cmcTransferService.selectCmcTransferByTransferId(formId);
1861
+            if (cmcTransfer != null)
1862
+                flowTaskDto.setTitle(cmcTransfer.getBeforeDept().getDeptName() + "-" + cmcTransfer.getAfterDept().getDeptName());
1863
+        }
1864
+        if (flowTaskDto.getProcDefName().equals("招聘审批")) {
1865
+            CmcRecruit cmcRecruit = cmcRecruitService.selectCmcRecruitByRecruitId(formId);
1866
+            if (cmcRecruit != null)
1867
+                flowTaskDto.setTitle(cmcRecruit.getPostName() + "-" + cmcRecruit.getPostNumber() + "名" );
1868
+        }
1848 1869
     }
1849 1870
 }

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

@@ -27,55 +27,6 @@
27 27
             <artifactId>ruoyi-framework</artifactId>
28 28
         </dependency>
29 29
 
30
-        <!-- 向量数据库-->
31
-        <dependency>
32
-            <groupId>io.milvus</groupId>
33
-            <artifactId>milvus-sdk-java</artifactId>
34
-            <version>2.6.2</version>
35
-        </dependency>
36
-
37
-        <!-- LangChain4j -->
38
-        <dependency>
39
-            <groupId>dev.langchain4j</groupId>
40
-            <artifactId>langchain4j</artifactId>
41
-            <version>0.35.0</version>
42
-        </dependency>
43
-
44
-        <!-- LangChain4j Milvus 集成 -->
45
-        <dependency>
46
-            <groupId>dev.langchain4j</groupId>
47
-            <artifactId>langchain4j-milvus</artifactId>
48
-            <version>0.35.0</version>
49
-        </dependency>
50
-
51
-        <!-- LangChain4j embedding 集成 -->
52
-        <dependency>
53
-            <groupId>dev.langchain4j</groupId>
54
-            <artifactId>langchain4j-embeddings-bge-small-zh-v15</artifactId>
55
-            <version>0.35.0</version>
56
-        </dependency>
57
-
58
-        <!-- LangChain4j rag 集成 -->
59
-        <dependency>
60
-            <groupId>dev.langchain4j</groupId>
61
-            <artifactId>langchain4j-easy-rag</artifactId>
62
-            <version>0.35.0</version>
63
-        </dependency>
64
-
65
-        <!-- LangChain4j pdfParser 集成 -->
66
-        <dependency>
67
-            <groupId>dev.langchain4j</groupId>
68
-            <artifactId>langchain4j-document-parser-apache-pdfbox</artifactId>
69
-            <version>0.35.0</version>
70
-        </dependency>
71
-
72
-        <!-- Solon模型上下文协议 -->
73
-        <dependency>
74
-            <groupId>org.noear</groupId>
75
-            <artifactId>solon-ai-mcp</artifactId>
76
-            <version>3.9.5</version>
77
-        </dependency>
78
-
79 30
         <!-- Spring Boot Web -->
80 31
         <dependency>
81 32
             <groupId>org.springframework.boot</groupId>

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

@@ -50,7 +50,6 @@ public class CmcAgentController extends BaseController
50 50
     @GetMapping("/list")
51 51
     public TableDataInfo list(CmcAgent cmcAgent)
52 52
     {
53
-        startPage();
54 53
         List<CmcAgent> list = cmcAgentService.selectCmcAgentList(cmcAgent);
55 54
         return getDataTable(list);
56 55
     }

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

@@ -41,7 +41,6 @@ public class CmcChatController extends BaseController
41 41
     @GetMapping("/list")
42 42
     public TableDataInfo list(CmcChat cmcChat)
43 43
     {
44
-        startPage();
45 44
         List<CmcChat> list = cmcChatService.selectCmcChatList(cmcChat);
46 45
         return getDataTable(list);
47 46
     }

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

@@ -42,7 +42,6 @@ public class CmcDocumentController extends BaseController
42 42
     @GetMapping("/list")
43 43
     public TableDataInfo list(CmcDocument cmcDocument)
44 44
     {
45
-        startPage();
46 45
         List<CmcDocument> list = cmcDocumentService.selectCmcDocumentList(cmcDocument);
47 46
         return getDataTable(list);
48 47
     }

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

@@ -51,7 +51,6 @@ public class CmcTopicController extends BaseController
51 51
     @GetMapping("/list")
52 52
     public TableDataInfo list(CmcTopic cmcTopic)
53 53
     {
54
-        startPage();
55 54
         List<CmcTopic> list = cmcTopicService.selectCmcTopicList(cmcTopic);
56 55
         return getDataTable(list);
57 56
     }

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

@@ -1,8 +1,8 @@
1 1
 package com.ruoyi.web.llm.controller;
2 2
 
3 3
 import com.alibaba.fastjson2.JSONArray;
4
-import com.ruoyi.web.llm.service.ILangChainMilvusService;
5
-import com.ruoyi.web.llm.service.IMilvusService;
4
+import com.ruoyi.llm.service.ILangChainMilvusService;
5
+import com.ruoyi.llm.service.IMilvusService;
6 6
 import com.ruoyi.common.core.controller.BaseController;
7 7
 import com.ruoyi.common.core.domain.AjaxResult;
8 8
 import org.springframework.beans.factory.annotation.Autowired;

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

@@ -1,100 +0,0 @@
1
-package com.ruoyi.web.llm.controller;
2
-
3
-import com.alibaba.fastjson2.JSONObject;
4
-import com.ruoyi.common.core.controller.BaseController;
5
-import com.ruoyi.llm.domain.CmcChat;
6
-import com.ruoyi.llm.service.ICmcAgentService;
7
-import com.ruoyi.llm.service.ICmcChatService;
8
-import com.ruoyi.llm.service.ICmcTopicService;
9
-import org.noear.solon.ai.chat.ChatModel;
10
-import org.noear.solon.ai.chat.ChatResponse;
11
-import org.noear.solon.ai.chat.ChatSession;
12
-import org.noear.solon.ai.chat.message.AssistantMessage;
13
-import org.noear.solon.ai.chat.message.ChatMessage;
14
-import org.noear.solon.ai.chat.session.InMemoryChatSession;
15
-import org.noear.solon.ai.mcp.McpChannel;
16
-import org.noear.solon.ai.mcp.client.McpClientProvider;
17
-import org.springframework.beans.factory.annotation.Autowired;
18
-import org.springframework.beans.factory.annotation.Value;
19
-import org.springframework.web.bind.annotation.GetMapping;
20
-import org.springframework.web.bind.annotation.RequestMapping;
21
-import org.springframework.web.bind.annotation.RestController;
22
-
23
-import java.io.IOException;
24
-import java.util.*;
25
-
26
-
27
-/**
28
- * mcp模型上下文协议Controller
29
- * 
30
- * @author cmc
31
- * @date 2025-04-08
32
- */
33
-@RestController
34
-@RequestMapping("/llm/mcp")
35
-public class McpController extends BaseController
36
-{
37
-    @Autowired
38
-    private ICmcChatService cmcChatService;
39
-
40
-    @Autowired
41
-    private ICmcTopicService cmcTopicService;
42
-
43
-    @Autowired
44
-    private ICmcAgentService cmcAgentService;
45
-
46
-    @Value("${cmc.llmService.url}")
47
-    private String llmServiceUrl;
48
-
49
-    @Value("${cmc.milvusService.url}")
50
-    private String milvusServiceUrl;
51
-
52
-    /**
53
-     * 自动调用mcp工具问答
54
-     * @return
55
-     */
56
-    @GetMapping("/answer")
57
-    public AssistantMessage answer(String topicId, String question) throws IOException {
58
-        McpClientProvider clientProvider = McpClientProvider.builder()
59
-                .channel(McpChannel.STREAMABLE_STATELESS )
60
-                .url("http://localhost:8087/mcp/sse")
61
-                .build();
62
-        ChatModel chatModel = ChatModel.of(llmServiceUrl)
63
-                .model("Qwen")
64
-                .defaultToolAdd(clientProvider)
65
-                .build();
66
-
67
-        List<ChatMessage> messages = new ArrayList<>();
68
-        CmcChat cmcChat = new CmcChat();
69
-        cmcChat.setTopicId(topicId);
70
-        List<CmcChat> cmcChatList = cmcChatService.selectCmcChatList(cmcChat);
71
-        for (CmcChat chat : cmcChatList) {
72
-            messages.add(ChatMessage.ofUser(chat.getInput()));
73
-            messages.add(ChatMessage.ofAssistant(chat.getOutput()));
74
-        }
75
-        messages.add(ChatMessage.ofUser(question));
76
-        ChatSession chatSession =  InMemoryChatSession.builder().messages(messages).build();
77
-        ChatResponse response = chatModel.prompt(chatSession).call();
78
-        String resultContent = response.lastChoice().getMessage().getResultContent();
79
-        AssistantMessage assistantMessage;
80
-        if (resultContent.startsWith("<tool_call>")) {
81
-            String content = resultContent.replace("<tool_call>\n", "").replace("\n</tool_call>", "");
82
-            JSONObject jsonObject = JSONObject.parseObject(content);
83
-            String name = jsonObject.getString("name");
84
-            JSONObject arguments = jsonObject.getJSONObject("arguments");
85
-            if (arguments.getString("templatePath").contains("招标") || arguments.getString("templatePath").contains("询价")) {
86
-                arguments.put("collectionName", "technical");
87
-                String agentName = cmcAgentService.selectCmcAgentByAgentId(cmcTopicService.selectCmcTopicByTopicId(topicId).getAgentId()).getAgentName();
88
-                arguments.put("agentName", agentName);
89
-                arguments.put("title", question);
90
-            }
91
-            resultContent = clientProvider.callTool(name, arguments).getContent();
92
-            assistantMessage = ChatMessage.ofAssistant(resultContent);
93
-        }
94
-        else
95
-            throw new IOException("模型上下文工具未准备就绪,请重试");
96
-        return assistantMessage;
97
-    }
98
-
99
-
100
-}

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

@@ -1,16 +1,14 @@
1 1
 package com.ruoyi.web.llm.controller;
2 2
 
3 3
 import com.alibaba.fastjson2.JSONObject;
4
-import com.ruoyi.web.llm.service.ILangChainMilvusService;
4
+import com.ruoyi.llm.service.ILangChainMilvusService;
5 5
 import com.ruoyi.common.core.controller.BaseController;
6
-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;
9 8
 import org.springframework.web.bind.annotation.RequestMapping;
10 9
 import org.springframework.web.bind.annotation.RestController;
11
-import reactor.core.publisher.Flux;
10
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
12 11
 
13
-import java.io.IOException;
14 12
 import java.util.List;
15 13
 
16 14
 /**
@@ -29,8 +27,8 @@ public class RagController extends BaseController
29 27
     /**
30 28
      * 调用LLM+RAG(知识库)生成回答
31 29
      */
32
-    @GetMapping(value = "/answer", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
33
-    public Flux<String> answerWithCollection(String collectionName, String topicId, String question) throws IOException {
30
+    @GetMapping("/answer")
31
+    public SseEmitter answerWithCollection(String collectionName, String topicId, String question) {
34 32
         List<JSONObject> contexts = langChainMilvusService.retrieveFromMilvus(collectionName, question, 10);
35 33
         return langChainMilvusService.generateAnswerWithCollection(topicId, question, contexts);
36 34
     }

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

@@ -1,14 +1,12 @@
1 1
 package com.ruoyi.web.llm.controller;
2 2
 
3 3
 import com.ruoyi.common.core.controller.BaseController;
4
-import com.ruoyi.web.llm.service.ISessionService;
5
-import org.noear.solon.core.util.MimeType;
4
+import com.ruoyi.llm.service.ISessionService;
6 5
 import org.springframework.beans.factory.annotation.Autowired;
7 6
 import org.springframework.web.bind.annotation.GetMapping;
8 7
 import org.springframework.web.bind.annotation.RequestMapping;
9 8
 import org.springframework.web.bind.annotation.RestController;
10
-import reactor.core.publisher.Flux;
11
-
9
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
12 10
 /**
13 11
  * session对话Controller
14 12
  *
@@ -25,16 +23,16 @@ public class SessionController extends BaseController
25 23
     /**
26 24
      * 生成回答
27 25
      */
28
-    @GetMapping(value = "/answer", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
29
-    public Flux<String> answer(String topicId, String question) {
26
+    @GetMapping("/answer")
27
+    public SseEmitter answer(String topicId, String question) {
30 28
         return sessionService.answer(topicId, question);
31 29
     }
32 30
 
33 31
     /**
34 32
      * 调用LLM+RAG(外部文件)生成回答
35 33
      */
36
-    @GetMapping(value = "/answerWithDocument", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
37
-    public Flux<String> answerWithDocument(String topicId, String chatId, String question) throws Exception
34
+    @GetMapping("/answerWithDocument")
35
+    public SseEmitter answerWithDocument(String topicId, String chatId, String question) throws Exception
38 36
     {
39 37
         return sessionService.answerWithDocument(topicId, chatId, question);
40 38
     }

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

@@ -1,17 +0,0 @@
1
-package com.ruoyi.web.llm.service;
2
-
3
-import reactor.core.publisher.Flux;
4
-
5
-public interface ISessionService {
6
-
7
-    /**
8
-     * 生成回答
9
-     */
10
-    Flux<String> answer(String topicId, String question);
11
-
12
-    /**
13
-     * 调用LLM+RAG(外部文件)生成回答
14
-     */
15
-    Flux<String> answerWithDocument(String topicId, String chatId, String question) throws Exception;
16
-
17
-}

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

@@ -1,782 +0,0 @@
1
-package com.ruoyi.web.llm.service.impl;
2
-
3
-import com.alibaba.fastjson2.JSONObject;
4
-import com.google.gson.JsonObject;
5
-import com.google.gson.JsonParser;
6
-import com.ruoyi.common.config.RuoYiConfig;
7
-import com.ruoyi.llm.domain.CmcChat;
8
-import com.ruoyi.llm.domain.CmcDocument;
9
-import com.ruoyi.llm.service.ICmcChatService;
10
-import com.ruoyi.llm.service.ICmcDocumentService;
11
-import com.ruoyi.web.llm.service.ILangChainMilvusService;
12
-import dev.langchain4j.data.document.Document;
13
-import dev.langchain4j.data.document.parser.TextDocumentParser;
14
-import dev.langchain4j.data.document.parser.apache.pdfbox.ApachePdfBoxDocumentParser;
15
-import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter;
16
-import dev.langchain4j.data.embedding.Embedding;
17
-import dev.langchain4j.data.segment.TextSegment;
18
-import dev.langchain4j.model.embedding.EmbeddingModel;
19
-import dev.langchain4j.model.embedding.onnx.bgesmallzhv15.BgeSmallZhV15EmbeddingModel;
20
-import dev.langchain4j.store.embedding.EmbeddingMatch;
21
-import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
22
-import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
23
-import io.milvus.v2.client.ConnectConfig;
24
-import io.milvus.v2.client.MilvusClientV2;
25
-import io.milvus.v2.common.IndexParam;
26
-import io.milvus.v2.service.collection.request.LoadCollectionReq;
27
-import io.milvus.v2.service.collection.request.ReleaseCollectionReq;
28
-import io.milvus.v2.service.vector.request.DeleteReq;
29
-import io.milvus.v2.service.vector.request.InsertReq;
30
-import io.milvus.v2.service.vector.request.SearchReq;
31
-import io.milvus.v2.service.vector.request.data.BaseVector;
32
-import io.milvus.v2.service.vector.request.data.FloatVec;
33
-import io.milvus.v2.service.vector.response.InsertResp;
34
-import io.milvus.v2.service.vector.response.SearchResp;
35
-import org.apache.poi.hwpf.HWPFDocument;
36
-import org.apache.poi.hwpf.usermodel.Paragraph;
37
-import org.apache.poi.hwpf.usermodel.Range;
38
-import org.apache.poi.xwpf.usermodel.XWPFDocument;
39
-import org.apache.poi.xwpf.usermodel.XWPFParagraph;
40
-import org.apache.poi.xwpf.usermodel.XWPFStyle;
41
-import org.noear.solon.ai.chat.ChatModel;
42
-import org.noear.solon.ai.chat.ChatResponse;
43
-import org.noear.solon.ai.chat.ChatSession;
44
-import org.noear.solon.ai.chat.message.ChatMessage;
45
-import org.noear.solon.ai.chat.prompt.Prompt;
46
-import org.noear.solon.ai.chat.session.InMemoryChatSession;
47
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrGeneral;
48
-import org.reactivestreams.Publisher;
49
-import org.springframework.beans.factory.annotation.Autowired;
50
-import org.springframework.beans.factory.annotation.Value;
51
-import org.springframework.stereotype.Service;
52
-import org.springframework.web.multipart.MultipartFile;
53
-import reactor.core.publisher.Flux;
54
-
55
-import javax.annotation.PostConstruct;
56
-import javax.annotation.PreDestroy;
57
-import java.io.*;
58
-import java.util.*;
59
-
60
-@Service
61
-public class LangChainMilvusServiceImpl implements ILangChainMilvusService
62
-{
63
-    @Autowired
64
-    private ICmcChatService cmcChatService;
65
-
66
-    @Autowired
67
-    private ICmcDocumentService cmcDocumentService;
68
-
69
-    private static final EmbeddingModel embeddingModel = new BgeSmallZhV15EmbeddingModel();
70
-
71
-    private String processValue = "";
72
-
73
-    @Value("${cmc.llmService.url}")
74
-    private String llmServiceUrl;
75
-
76
-    @Value("${cmc.milvusService.url}")
77
-    private String milvusServiceUrl;
78
-
79
-    private MilvusClientV2 milvusClient;
80
-
81
-    // 复用 ChatModel 实例
82
-    private ChatModel chatModel;
83
-
84
-    @PostConstruct
85
-    public void initMilvusClient() {
86
-        if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
87
-            throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
88
-        }
89
-        milvusClient = new MilvusClientV2(
90
-                ConnectConfig.builder()
91
-                        .uri(milvusServiceUrl)
92
-                        .build());
93
-    }
94
-
95
-    @PostConstruct
96
-    public void initChatModel() {
97
-        // 初始化 ChatModel
98
-        chatModel = ChatModel.of(llmServiceUrl)
99
-                .model("Qwen")
100
-                .build();
101
-    }
102
-
103
-    @PreDestroy
104
-    public void destroyMilvusClient() {
105
-        if (milvusClient != null) {
106
-            try {
107
-                // 关闭 Milvus 客户端,释放 gRPC 通道
108
-                milvusClient.close();
109
-                System.out.println("Milvus client closed successfully");
110
-            } catch (Exception e) {
111
-                System.err.println("Error closing Milvus client: " + e.getMessage());
112
-                e.printStackTrace();
113
-            }
114
-        }
115
-    }
116
-
117
-    /**
118
-     * 上传多文件
119
-     *
120
-     * @return 结果
121
-     */
122
-    public String getProcess() {
123
-        return processValue;
124
-    }
125
-
126
-    /**
127
-     * 导入知识库文件
128
-     * @return
129
-     */
130
-    @Override
131
-    public int insertLangchainEmbeddingDocument(MultipartFile[] fileList, String collectionName)
132
-    {
133
-        processValue = "";
134
-        int successfullyInsertedFiles = 0;
135
-
136
-        for (int i = 0; i < fileList.length; i++) {
137
-            MultipartFile file = fileList[i];
138
-            try {
139
-                // 构建上传目录路径
140
-                String uploadDir = RuoYiConfig.getProfile() + "/upload/rag/knowledge/" + collectionName;
141
-                File profilePath = new File(uploadDir);
142
-
143
-                // 确保目录存在,创建目录并设置权限
144
-                if (!profilePath.exists())
145
-                    profilePath.mkdirs();
146
-                File transferFile = new File(profilePath, file.getOriginalFilename());
147
-                if (!transferFile.exists())
148
-                    file.transferTo(transferFile);
149
-                List<TextSegment> segments = splitDocument(transferFile);
150
-
151
-                // 准备导入数据
152
-                List<JsonObject> data = new ArrayList<>();
153
-                // 提取文本和生成嵌入
154
-                for (TextSegment segment : segments) {
155
-                    String text = segment.text();
156
-                    if (text.trim().isEmpty())
157
-                        continue;
158
-
159
-                    JSONObject fastjsonObj = new JSONObject();
160
-                    fastjsonObj.put("file_name", file.getOriginalFilename());
161
-                    String[] fileName = file.getOriginalFilename().split("\\.");
162
-                    fastjsonObj.put("file_type", fileName[fileName.length - 1]);
163
-                    fastjsonObj.put("title", text.split("\n")[0].split(" ")[1]);
164
-                    fastjsonObj.put("content", text);
165
-                    fastjsonObj.put("embedding", embeddingModel.embed(text).content().vectorAsList());
166
-                    String jsonString = fastjsonObj.toJSONString();
167
-                    JsonObject jsonObject = JsonParser.parseString(jsonString).getAsJsonObject();
168
-                    data.add(jsonObject);
169
-                }
170
-
171
-                // 添加一条包含所有二、三级标题的记录
172
-                String titlesContent = extractAllTitles(transferFile);
173
-                if (!titlesContent.trim().isEmpty()) {
174
-                    JSONObject titlesJsonObj = new JSONObject();
175
-                    titlesJsonObj.put("file_name", file.getOriginalFilename());
176
-                    titlesJsonObj.put("file_type", "标题");
177
-                    titlesJsonObj.put("title", "");
178
-                    titlesJsonObj.put("content", titlesContent);
179
-                    titlesJsonObj.put("embedding", embeddingModel.embed(titlesContent).content().vectorAsList());
180
-                    String titlesJsonString = titlesJsonObj.toJSONString();
181
-                    JsonObject titlesJsonObject = JsonParser.parseString(titlesJsonString).getAsJsonObject();
182
-                    data.add(titlesJsonObject);
183
-                }
184
-
185
-                // 加载集合
186
-                LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
187
-                        .collectionName(collectionName)
188
-                        .build();
189
-                milvusClient.loadCollection(loadCollectionReq);
190
-
191
-                try {
192
-                    // 先删除相同文件名的记录
193
-                    DeleteReq deleteReq = DeleteReq.builder()
194
-                            .collectionName(collectionName)
195
-                            .filter(String.format("file_name == \"%s\"", file.getOriginalFilename()))
196
-                            .build();
197
-                    if (milvusClient.delete(deleteReq).getDeleteCnt() > 0)
198
-                        System.out.println("已删除同名文件记录: " + file.getOriginalFilename());
199
-
200
-                    // 构建导入请求
201
-                    InsertReq insertReq = InsertReq.builder()
202
-                            .collectionName(collectionName)
203
-                            .data(data)
204
-                            .build();
205
-
206
-                    // 执行导入并检查结果
207
-                    InsertResp insertResp = milvusClient.insert(insertReq);
208
-                    if (insertResp != null && insertResp.getInsertCnt() > 0) {
209
-                        successfullyInsertedFiles++;
210
-                        processValue = "上传中: " + String.format("%.2f", (double) (i + 1) / fileList.length * 100) + "%";
211
-                        System.out.println("成功导入" + (i + 1) + "/" + fileList.length + "文件: " + file.getOriginalFilename() + ", 导入记录数: " + insertResp.getInsertCnt());
212
-                    } else {
213
-                        System.err.println("文件导入失败: " + file.getOriginalFilename() + ", 没有记录被导入");
214
-                    }
215
-                } finally {
216
-                    // 释放集合
217
-                    ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
218
-                            .collectionName(collectionName)
219
-                            .build();
220
-                    milvusClient.releaseCollection(releaseCollectionReq);
221
-                }
222
-            } catch (Exception e) {
223
-                System.err.println("处理文件时出错: " + file.getOriginalFilename() + ", 错误: " + e.getMessage());
224
-                e.printStackTrace();
225
-                // 继续处理下一个文件
226
-            }
227
-        }
228
-
229
-        processValue = "上传完成";
230
-        return successfullyInsertedFiles;
231
-    }
232
-
233
-    /**
234
-     * 从Milvus检索相关文档
235
-     * @return
236
-     */
237
-    @Override
238
-    public List<JSONObject> retrieveFromMilvus(String collectionName, String query, int topK) {
239
-        List<JSONObject> resultList = new ArrayList<>();
240
-        List<List<SearchResp.SearchResult>> searchResultList = retrieve(collectionName, query, topK);
241
-        searchResultList.forEach(searchResult -> {
242
-            JSONObject result = new JSONObject();
243
-            result.put("file_name", searchResult.get(0).getEntity().get("file_name"));
244
-            result.put("content", searchResult.get(0).getEntity().get("content"));
245
-            resultList.add(result);
246
-        });
247
-        return resultList;
248
-    }
249
-
250
-    /**
251
-     * 从Milvus检索相关文档及相关度
252
-     * @return
253
-     */
254
-    @Override
255
-    public List<JSONObject> similarityFromMilvus(String collectionName, String query, int topK) {
256
-        List<JSONObject> resultList = new ArrayList<>();
257
-        List<List<SearchResp.SearchResult>> searchResultList = retrieve(collectionName, query, topK);
258
-        searchResultList.forEach(searchResult -> {
259
-            JSONObject result = new JSONObject();
260
-            result.put("score", searchResult.get(0).getScore());
261
-            result.put("file_name", searchResult.get(0).getEntity().get("file_name"));
262
-            result.put("content", searchResult.get(0).getEntity().get("content"));
263
-            resultList.add(result);
264
-        });
265
-        resultList.removeIf(jsonObject -> jsonObject.getDouble("score") < 0.7);
266
-        return resultList;
267
-    }
268
-
269
-    /**
270
-     * 调用LLM生成回答
271
-     * @return
272
-     */
273
-    @Override
274
-    public Flux<String> generateAnswer(String topicId, String prompt) {
275
-        List<ChatMessage> messages = new ArrayList<>();
276
-        if (topicId != null) {
277
-            CmcChat cmcChat = new CmcChat();
278
-            cmcChat.setTopicId(topicId);
279
-            List<CmcChat> cmcChatList = cmcChatService.selectCmcChatList(cmcChat);
280
-            for (CmcChat chat : cmcChatList) {
281
-                messages.add(ChatMessage.ofUser(chat.getInput()));
282
-                messages.add(ChatMessage.ofAssistant(chat.getOutput()));
283
-            }
284
-        }
285
-        messages.add(ChatMessage.ofUser(prompt));
286
-        ChatSession chatSession =  InMemoryChatSession.builder().messages(messages).build();
287
-        Prompt prompt1 = Prompt.of(prompt).attrPut("session", chatSession);
288
-        Publisher<ChatResponse> publisher = chatModel.prompt(prompt1).stream();
289
-        return Flux.from(publisher)
290
-                .map(response -> response.lastChoice().getMessage().getContent())
291
-                .map(this::toSseDataFrame)
292
-                .concatWith(Flux.just("data: [DONE]\n\n"));
293
-    }
294
-
295
-    private String toSseDataFrame(String data) {
296
-        if (data == null) {
297
-            return "data: \n\n";
298
-        }
299
-        // SSE 要求每一行都以 data: 开头
300
-        String normalized = data.replace("\r\n", "\n");
301
-        StringBuilder sb = new StringBuilder(normalized.length() + 16);
302
-        sb.append("data: ");
303
-        int start = 0;
304
-        while (true) {
305
-            int idx = normalized.indexOf('\n', start);
306
-            if (idx < 0) {
307
-                sb.append(normalized.substring(start));
308
-                break;
309
-            }
310
-            sb.append(normalized, start, idx);
311
-            sb.append("\n");
312
-            sb.append("data: ");
313
-            start = idx + 1;
314
-        }
315
-        sb.append("\n\n");
316
-        return sb.toString();
317
-    }
318
-
319
-    /**
320
-     * 调用LLM+RAG生成回答
321
-     * @return
322
-     */
323
-    @Override
324
-    public Flux<String> generateAnswerWithCollection(String topicId, String question, List<JSONObject> contexts) {
325
-        StringBuilder sb = new StringBuilder();
326
-        sb.append("问题: ").append(question).append("\n\n");
327
-        sb.append("根据以下上下文回答问题:\n\n");
328
-        for (JSONObject context : contexts) {
329
-            sb.append("文件").append(": ")
330
-                    .append(context.getString("file_name")).append("\n\n")
331
-                    .append("上下文").append(": ")
332
-                    .append(context.getString("content")).append("\n\n");
333
-        }
334
-        return generateAnswer(topicId, sb.toString());
335
-    }
336
-
337
-    /**
338
-     * 调用LLM生成回答
339
-     */
340
-    @Override
341
-    public Flux<String> generateAnswerWithDocument(String topicId, String chatId, String question) throws Exception {
342
-        CmcDocument cmcDocument = new CmcDocument();
343
-        cmcDocument.setChatId(chatId);
344
-        List<CmcDocument> documentList = cmcDocumentService.selectCmcDocumentList(cmcDocument);
345
-        StringBuilder sb = new StringBuilder("问题: " + question + "\n\n").append("根据以下上下文回答问题:\n\n");
346
-        for (CmcDocument document : documentList) {
347
-            File profilePath = new File(RuoYiConfig.getProfile() + document.getPath());
348
-            InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
349
-            List<TextSegment> segments = splitDocument(profilePath);
350
-            List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
351
-            embeddingStore.addAll(embeddings, segments);
352
-            Embedding queryEmbedding = embeddingModel.embed(question).content();
353
-            EmbeddingSearchRequest embeddingSearchRequest = EmbeddingSearchRequest.builder()
354
-                    .queryEmbedding(queryEmbedding)
355
-                    .minScore(0.7)
356
-                    .build();
357
-            List<EmbeddingMatch<TextSegment>> results = embeddingStore.search(embeddingSearchRequest).matches();
358
-            results.sort(Comparator.comparingDouble(EmbeddingMatch<TextSegment>::score).reversed());
359
-            for (EmbeddingMatch<TextSegment> embeddingMatch : results) {
360
-                String contexts = embeddingMatch.embedded().toString();
361
-                sb.append("文件").append(": ")
362
-                        .append(document.getPath()).append("\n\n")
363
-                        .append("上下文").append(": ")
364
-                        .append(contexts).append("\n\n");
365
-            }
366
-        }
367
-        return generateAnswer(topicId, sb.toString());
368
-    }
369
-
370
-    /**
371
-     * 调用LLM生成回答
372
-     */
373
-    @Override
374
-    public Flux<String> generateAnswerWithDocumentAndCollection(String topicId, String question, List<JSONObject> contexts) throws Exception {
375
-        StringBuilder sb = new StringBuilder("招标文件内容:\n\n");
376
-        CmcChat cmcChat = new CmcChat();
377
-        cmcChat.setTopicId(topicId);
378
-        for (CmcChat chat : cmcChatService.selectCmcChatList(cmcChat)) {
379
-            CmcDocument cmcDocument = new CmcDocument();
380
-            cmcDocument.setChatId(chat.getChatId());
381
-            List<CmcDocument> documentList = cmcDocumentService.selectCmcDocumentList(cmcDocument);
382
-            //技术标书撰写智能体一个话题只会上传一个招标文件
383
-            if (documentList.size() == 1) {
384
-                for (CmcDocument document : documentList) {
385
-                    File profilePath = new File(RuoYiConfig.getProfile() + document.getPath());
386
-                    InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
387
-                    List<TextSegment> segments = splitDocument(profilePath);
388
-                    List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
389
-                    embeddingStore.addAll(embeddings, segments);
390
-                    Embedding queryEmbedding = embeddingModel.embed(question).content();
391
-                    EmbeddingSearchRequest embeddingSearchRequest = EmbeddingSearchRequest.builder()
392
-                            .queryEmbedding(queryEmbedding)
393
-                            .minScore(0.7)
394
-                            .build();
395
-                    List<EmbeddingMatch<TextSegment>> results = embeddingStore.search(embeddingSearchRequest).matches();
396
-                    results.sort(Comparator.comparingDouble(EmbeddingMatch<TextSegment>::score).reversed());
397
-                    for (EmbeddingMatch<TextSegment> embeddingMatch : results) {
398
-                        String requests = embeddingMatch.embedded().toString();
399
-                        sb.append(requests).append("\n\n");
400
-                    }
401
-                }
402
-            }
403
-        }
404
-        sb.append("针对本项目招标文件内容,补全以下章节部分:\n\n").append(question);
405
-//        for (JSONObject context : contexts) {
406
-//            sb.append("文件").append(": ")
407
-//                    .append(context.getString("file_name")).append("\n\n")
408
-//                    .append("段落格式").append(": ")
409
-//                    .append(context.getString("content")).append("\n\n");
410
-//        }
411
-        return generateAnswer(topicId, sb.toString());
412
-    }
413
-
414
-    /**
415
-     * 获取二级标题下三级标题列表
416
-     */
417
-    @Override
418
-    public List<String> extractSubTitles(String filename, String question) throws IOException {
419
-        List<String> subTitles = new ArrayList<>();
420
-        boolean inTargetSection = false;
421
-
422
-        InputStream fileInputStream = new FileInputStream(filename);
423
-        try (XWPFDocument document = new XWPFDocument(fileInputStream)) {
424
-            for (XWPFParagraph paragraph : document.getParagraphs()) {
425
-                String text = paragraph.getText().trim();
426
-                int level = getDocxOutlineLevel(paragraph, document);
427
-                if (level == 3 && text.contains(question)) {
428
-                    inTargetSection = true;
429
-                    continue;
430
-                }
431
-
432
-                if (inTargetSection) {
433
-                    if (level == 4) {
434
-                        subTitles.add(text);
435
-                    }
436
-                    else if (level == 3) {
437
-                        break;
438
-                    }
439
-                }
440
-            }
441
-        }
442
-        if (subTitles.size() == 0)
443
-            subTitles.add(question);
444
-        return subTitles;
445
-    }
446
-
447
-    /**
448
-     * 检索知识库
449
-     * @return
450
-     */
451
-    private List<List<SearchResp.SearchResult>> retrieve(String collectionName, String query, int topK) {
452
-        List<BaseVector> queryVector = Collections.singletonList(new FloatVec(embeddingModel.embed(query).content().vector()));
453
-
454
-        //  加载集合
455
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
456
-                .collectionName(collectionName)
457
-                .build();
458
-        milvusClient.loadCollection(loadCollectionReq);
459
-
460
-        // 构建SearchParam
461
-        Map<String, Object> searchParams = new HashMap<>();
462
-        searchParams.put("nprobe", 8);
463
-        SearchReq searchReq = SearchReq.builder()
464
-                .collectionName(collectionName)
465
-                .data(queryVector)
466
-                .topK(topK)
467
-                .outputFields(Arrays.asList("file_name", "file_type", "content"))
468
-                .annsField("embedding")
469
-                .metricType(IndexParam.MetricType.COSINE)
470
-                .searchParams(searchParams)
471
-                .build();
472
-
473
-        SearchResp searchResp = milvusClient.search(searchReq);
474
-        List<List<SearchResp.SearchResult>> searchResultList = searchResp.getSearchResults();
475
-
476
-        // 释放集合
477
-        ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
478
-                .collectionName(collectionName)
479
-                .build();
480
-        milvusClient.releaseCollection(releaseCollectionReq);
481
-
482
-        return searchResultList;
483
-    }
484
-
485
-    /**
486
-     * 提取文档中的所有二、三级标题
487
-     */
488
-    private String extractAllTitles(File transferFile) throws Exception {
489
-        StringBuilder titlesBuilder = new StringBuilder();
490
-        String filename = transferFile.getName().toLowerCase();
491
-
492
-        if (filename.endsWith(".docx")) {
493
-            try (XWPFDocument document = new XWPFDocument(new FileInputStream(transferFile))) {
494
-                for (XWPFParagraph paragraph : document.getParagraphs()) {
495
-                    String text = paragraph.getText().trim();
496
-                    if (text.isEmpty()) continue;
497
-                    int outlineLevel = getDocxOutlineLevel(paragraph, document);
498
-                    if (outlineLevel == 2 || outlineLevel == 3) {
499
-                        titlesBuilder.append(text).append("\n");
500
-                    }
501
-                }
502
-            }
503
-        } else if (filename.endsWith(".doc")) {
504
-            try (HWPFDocument document = new HWPFDocument(new FileInputStream(transferFile))) {
505
-                Range range = document.getRange();
506
-                for (int i = 0; i < range.numParagraphs(); i++) {
507
-                    Paragraph paragraph = range.getParagraph(i);
508
-                    String text = paragraph.text().trim();
509
-                    if (text.isEmpty()) continue;
510
-                    int outlineLevel = getDocOutlineLevel(paragraph);
511
-                    if (outlineLevel == 2 || outlineLevel == 3) {
512
-                        titlesBuilder.append(text).append("\n");
513
-                    }
514
-                }
515
-            }
516
-        }
517
-        return titlesBuilder.toString();
518
-    }
519
-
520
-    /**
521
-     * 获取DOCX段落的大纲级别
522
-     */
523
-    private int getDocxOutlineLevel(XWPFParagraph paragraph, XWPFDocument document) {
524
-        int level = 0;
525
-        String styleId = paragraph.getStyleID();
526
-        if (styleId != null) {
527
-            XWPFStyle style = document.getStyles().getStyle(styleId);
528
-            if (style != null) {
529
-                CTPPrGeneral stylePPr = style.getCTStyle().getPPr();
530
-                if (stylePPr != null && stylePPr.getOutlineLvl() != null) {
531
-                    level = stylePPr.getOutlineLvl().getVal().intValue() + 1;
532
-                }
533
-            }
534
-        }
535
-        return level;
536
-    }
537
-
538
-    /**
539
-     * 获取DOC段落的大纲级别
540
-     */
541
-    private int getDocOutlineLevel(Paragraph paragraph) {
542
-        int level = 0;
543
-        short styleIndex = paragraph.getStyleIndex();
544
-        if (styleIndex >= 1 && styleIndex <= 9) {
545
-            level = styleIndex;
546
-        }
547
-        return level;
548
-    }
549
-
550
-    /**
551
-     * 检查DOCX段落是否是三级标题
552
-     */
553
-    private boolean isDocxLevel3Title(String paraText, XWPFDocument document) {
554
-        for (XWPFParagraph p : document.getParagraphs()) {
555
-            if (p.getText().trim().equals(paraText.trim())) {
556
-                return getDocxOutlineLevel(p, document) == 3;
557
-            }
558
-        }
559
-        return false;
560
-    }
561
-
562
-    /**
563
-     * 按三级标题分割DOCX内容
564
-     */
565
-    private void splitDocxByLevel3(String content, XWPFDocument document, List<TextSegment> segments) {
566
-        StringBuilder currentLevel3Content = new StringBuilder();
567
-        String[] paragraphs = content.split("\n");
568
-        for (String para : paragraphs) {
569
-            if (para.trim().isEmpty()) continue;
570
-
571
-            if (isDocxLevel3Title(para.trim(), document)) {
572
-                // 保存当前三级标题内容
573
-                if (currentLevel3Content.length() != 0) {
574
-                    TextSegment segment = TextSegment.from(currentLevel3Content.toString());
575
-                    segments.add(segment);
576
-                    currentLevel3Content = new StringBuilder();
577
-                }
578
-                // 开始新的三级标题内容
579
-                currentLevel3Content.append(para).append("\n");
580
-            } else {
581
-                // 普通内容,添加到当前三级标题
582
-                if (currentLevel3Content.length() != 0) {
583
-                    currentLevel3Content.append(para).append("\n");
584
-                }
585
-            }
586
-        }
587
-        // 保存最后一个三级标题内容
588
-        if (currentLevel3Content.length() != 0) {
589
-            TextSegment segment = TextSegment.from(currentLevel3Content.toString());
590
-            segments.add(segment);
591
-        }
592
-    }
593
-
594
-    /**
595
-     * 按三级标题分割DOC内容
596
-     */
597
-    private void splitDocByLevel3(String content, HWPFDocument document, List<TextSegment> segments) {
598
-        StringBuilder currentLevel3Content = new StringBuilder();
599
-        Range level2Range = document.getRange();
600
-        boolean foundCurrentLevel2 = false;
601
-
602
-        for (int j = 0; j < level2Range.numParagraphs(); j++) {
603
-            Paragraph p = level2Range.getParagraph(j);
604
-            String paraText = p.text().trim();
605
-            if (paraText.isEmpty()) continue;
606
-
607
-            // 找到当前二级标题的开始
608
-            int pLevel = getDocOutlineLevel(p);
609
-            if (pLevel == 2 && paraText.equals(content.split("\n")[0].trim())) {
610
-                foundCurrentLevel2 = true;
611
-            }
612
-
613
-            if (foundCurrentLevel2) {
614
-                // 重新获取当前段落的大纲级别
615
-                pLevel = getDocOutlineLevel(p);
616
-
617
-                if (pLevel == 3) {
618
-                    // 三级标题
619
-                    // 保存当前三级标题内容
620
-                    if (currentLevel3Content.length() != 0) {
621
-                        TextSegment segment = TextSegment.from(currentLevel3Content.toString());
622
-                        segments.add(segment);
623
-                        currentLevel3Content = new StringBuilder();
624
-                    }
625
-                    // 开始新的三级标题内容
626
-                    currentLevel3Content.append(paraText).append("\n");
627
-                } else if (pLevel == 2 && !paraText.equals(content.split("\n")[0].trim())) {
628
-                    // 下一个二级标题,结束当前二级标题的处理
629
-                    break;
630
-                } else {
631
-                    // 普通内容或当前二级标题
632
-                    if (currentLevel3Content.length() != 0 || pLevel == 2) {
633
-                        currentLevel3Content.append(paraText).append("\n");
634
-                    }
635
-                }
636
-            }
637
-        }
638
-
639
-        // 保存最后一个三级标题内容
640
-        if (currentLevel3Content.length() != 0) {
641
-            TextSegment segment = TextSegment.from(currentLevel3Content.toString());
642
-            segments.add(segment);
643
-        }
644
-    }
645
-
646
-    /**
647
-     * 检索知识库
648
-     */
649
-    private List<TextSegment> splitDocument(File transferFile) throws Exception {
650
-        // 加载文档
651
-        Document document;
652
-        InputStream fileInputStream = new FileInputStream(transferFile);
653
-        String filename = transferFile.getName().toLowerCase();
654
-        if (filename.endsWith(".docx")) {
655
-            // 使用XWPFDocument处理DOCX文件,按段落层级分割
656
-            List<TextSegment> segments = new ArrayList<>();
657
-            try (XWPFDocument xwpfDocument = new XWPFDocument(fileInputStream)) {
658
-                StringBuilder currentLevel2Content = new StringBuilder();
659
-                boolean inLevel2Section = false;
660
-
661
-                for (XWPFParagraph paragraph : xwpfDocument.getParagraphs()) {
662
-                    String text = paragraph.getText().trim();
663
-                    if (text.isEmpty()) continue;
664
-
665
-                    int paraLevel = getDocxOutlineLevel(paragraph, xwpfDocument);
666
-
667
-                    // 二级标题(大纲级别2)开始新的分段
668
-                    if (paraLevel == 2) {
669
-                        // 保存当前二级标题下的内容
670
-                        if (currentLevel2Content.length() != 0) {
671
-                            // 检查当前二级标题内容的字节数
672
-                            int level2Length = currentLevel2Content.toString().getBytes().length;
673
-                            if (level2Length <= 65535) {
674
-                                // 字数不大于65535,按二级标题分割
675
-                                TextSegment segment = TextSegment.from(currentLevel2Content.toString());
676
-                                segments.add(segment);
677
-                            } else {
678
-                                // 字数大于65535,按三级标题分割
679
-                                splitDocxByLevel3(currentLevel2Content.toString(), xwpfDocument, segments);
680
-                            }
681
-                            currentLevel2Content = new StringBuilder();
682
-                        }
683
-                        // 开始新的二级标题内容
684
-                        currentLevel2Content.append(text).append("\n");
685
-                        inLevel2Section = true;
686
-                    }
687
-                    // 其他层级(包括三级标题)
688
-                    else {
689
-                        if (inLevel2Section) {
690
-                            currentLevel2Content.append(text).append("\n");
691
-                        }
692
-                    }
693
-                }
694
-
695
-                // 保存最后一个二级标题内容
696
-                if (currentLevel2Content.length() != 0) {
697
-                    // 检查字节数
698
-                    int level2Length = currentLevel2Content.toString().getBytes().length;
699
-                    if (level2Length <= 65535) {
700
-                        // 字数不大于65535,按二级标题分割
701
-                        TextSegment segment = TextSegment.from(currentLevel2Content.toString());
702
-                        segments.add(segment);
703
-                    } else {
704
-                        // 字数大于65535,按三级标题分割
705
-                        splitDocxByLevel3(currentLevel2Content.toString(), xwpfDocument, segments);
706
-                    }
707
-                }
708
-            }
709
-            return segments;
710
-        }
711
-        else if (filename.endsWith(".doc")) {
712
-            // 使用HWPFDocument处理DOC文件,按段落层级分割
713
-            List<TextSegment> segments = new ArrayList<>();
714
-            try (HWPFDocument hwpfDocument = new HWPFDocument(fileInputStream)) {
715
-                StringBuilder currentLevel2Content = new StringBuilder();
716
-                boolean inLevel2Section = false;
717
-
718
-                Range range = hwpfDocument.getRange();
719
-                for (int i = 0; i < range.numParagraphs(); i++) {
720
-                    Paragraph paragraph = range.getParagraph(i);
721
-                    String text = paragraph.text().trim();
722
-                    if (text.isEmpty()) continue;
723
-
724
-                    int paraLevel = getDocOutlineLevel(paragraph);
725
-
726
-                    if (paraLevel == 2) {
727
-                        // 二级标题,开始新的分段
728
-                        // 保存当前二级标题下的内容
729
-                        if (currentLevel2Content.length() != 0) {
730
-                            // 检查当前二级标题内容的字节数
731
-                            int level2Length = currentLevel2Content.toString().getBytes().length;
732
-                            if (level2Length <= 65535) {
733
-                                // 字数不大于65535,按二级标题分割
734
-                                TextSegment segment = TextSegment.from(currentLevel2Content.toString());
735
-                                segments.add(segment);
736
-                            } else {
737
-                                // 字数大于65535,按三级标题分割
738
-                                splitDocByLevel3(currentLevel2Content.toString(), hwpfDocument, segments);
739
-                            }
740
-                            currentLevel2Content = new StringBuilder();
741
-                        }
742
-                        // 开始新的二级标题内容
743
-                        currentLevel2Content.append(text).append("\n");
744
-                        inLevel2Section = true;
745
-                    } else {
746
-                        // 非二级标题或普通内容段落
747
-                        if (inLevel2Section) {
748
-                            currentLevel2Content.append(text).append("\n");
749
-                        }
750
-                    }
751
-                }
752
-
753
-                // 保存最后一个二级标题内容
754
-                if (currentLevel2Content.length() != 0) {
755
-                    // 检查字节数
756
-                    int level2Length = currentLevel2Content.toString().getBytes().length;
757
-                    if (level2Length <= 65535) {
758
-                        // 字数不大于65535,按二级标题分割
759
-                        TextSegment segment = TextSegment.from(currentLevel2Content.toString());
760
-                        segments.add(segment);
761
-                    } else {
762
-                        // 字数大于65535,按三级标题分割
763
-                        splitDocByLevel3(currentLevel2Content.toString(), hwpfDocument, segments);
764
-                    }
765
-                }
766
-            }
767
-            return segments;
768
-        }
769
-        else if (filename.endsWith(".pdf")) {
770
-            document = new ApachePdfBoxDocumentParser().parse(fileInputStream);
771
-        }
772
-        else if (filename.endsWith(".txt")) {
773
-            document = new TextDocumentParser().parse(fileInputStream);
774
-        }
775
-        else {
776
-            throw new UnsupportedOperationException("不支持文件类型: " + filename);
777
-        }
778
-        DocumentByParagraphSplitter splitter = new DocumentByParagraphSplitter(300,50);
779
-        return splitter.split(document);
780
-    }
781
-
782
-}

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

@@ -1,379 +0,0 @@
1
-package com.ruoyi.web.llm.service.impl;
2
-
3
-import com.alibaba.fastjson2.JSONArray;
4
-import com.alibaba.fastjson2.JSONObject;
5
-import com.ruoyi.web.llm.service.IMilvusService;
6
-import io.milvus.v2.client.ConnectConfig;
7
-import io.milvus.v2.client.MilvusClientV2;
8
-import io.milvus.v2.common.DataType;
9
-import io.milvus.v2.common.IndexParam;
10
-import io.milvus.v2.service.collection.request.*;
11
-import io.milvus.v2.service.collection.response.DescribeCollectionResp;
12
-import io.milvus.v2.service.collection.response.ListCollectionsResp;
13
-import io.milvus.v2.service.vector.request.DeleteReq;
14
-import io.milvus.v2.service.vector.request.QueryReq;
15
-import io.milvus.v2.service.vector.response.QueryResp;
16
-import org.springframework.beans.factory.annotation.Value;
17
-import org.springframework.stereotype.Service;
18
-
19
-import javax.annotation.PostConstruct;
20
-import javax.annotation.PreDestroy;
21
-import java.text.SimpleDateFormat;
22
-import java.util.*;
23
-import java.util.stream.Collectors;
24
-
25
-@Service
26
-public class MilvusServiceImpl implements IMilvusService {
27
-
28
-    @Value("${cmc.milvusService.url}")
29
-    private String milvusServiceUrl;
30
-
31
-    private MilvusClientV2 milvusClient;
32
-
33
-    @PostConstruct
34
-    public void initMilvusClient() {
35
-        if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
36
-            throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
37
-        }
38
-        milvusClient = new MilvusClientV2(
39
-                ConnectConfig.builder()
40
-                        .uri(milvusServiceUrl)
41
-                        .build());
42
-    }
43
-
44
-    @PreDestroy
45
-    public void destroyMilvusClient() {
46
-        if (milvusClient != null) {
47
-            try {
48
-                // 关闭 Milvus 客户端,释放 gRPC 通道
49
-                milvusClient.close();
50
-                System.out.println("Milvus client closed successfully");
51
-            } catch (Exception e) {
52
-                System.err.println("Error closing Milvus client: " + e.getMessage());
53
-                e.printStackTrace();
54
-            }
55
-        }
56
-    }
57
-
58
-    /**
59
-     * 新建知识库Collection(含Schema、Field、Index)
60
-     */
61
-    @Override
62
-    public void createCollection(String collectionName, String description, int dimension) {
63
-        CreateCollectionReq.CollectionSchema schema = MilvusClientV2.CreateSchema();
64
-
65
-        schema.addField(AddFieldReq.builder()
66
-                .fieldName("id")
67
-                .dataType(DataType.Int64)
68
-                .isPrimaryKey(true)
69
-                .autoID(true)
70
-                .build());
71
-
72
-        schema.addField(AddFieldReq.builder()
73
-                .fieldName("file_name")
74
-                .dataType(DataType.VarChar)
75
-                .maxLength(256)
76
-                .build());
77
-
78
-        schema.addField(AddFieldReq.builder()
79
-                .fieldName("title")
80
-                .dataType(DataType.VarChar)
81
-                .maxLength(256)
82
-                .build());
83
-
84
-        schema.addField(AddFieldReq.builder()
85
-                .fieldName("file_type")
86
-                .dataType(DataType.VarChar)
87
-                .maxLength(10)
88
-                .build());
89
-
90
-        schema.addField(AddFieldReq.builder()
91
-                .fieldName("content")
92
-                .dataType(DataType.VarChar)
93
-                .maxLength(65535)
94
-                .build());
95
-
96
-        schema.addField(AddFieldReq.builder()
97
-                .fieldName("embedding")
98
-                .dataType(DataType.FloatVector)
99
-                .dimension(dimension)
100
-                .build());
101
-
102
-        // 创建索引
103
-        Map<String, Object> extraParams = new HashMap<>();
104
-        extraParams.put("nlist", 64);
105
-        IndexParam indexParam = IndexParam.builder()
106
-                .fieldName("embedding")
107
-                .indexType(IndexParam.IndexType.IVF_FLAT)
108
-                .metricType(IndexParam.MetricType.COSINE)
109
-                .extraParams(extraParams)
110
-                .build();
111
-
112
-        CreateCollectionReq createCollectionParam = CreateCollectionReq.builder()
113
-                .collectionName(collectionName)
114
-                .description(description)
115
-                .collectionSchema(schema)
116
-                .indexParam(indexParam)
117
-                .build();
118
-
119
-        milvusClient.createCollection(createCollectionParam);
120
-    }
121
-
122
-    /**
123
-     * 查询知识库Collection
124
-     */
125
-    @Override
126
-    public JSONArray getCollectionNames() {
127
-        JSONArray jsonArray = new JSONArray();
128
-        ListCollectionsResp listResponse = milvusClient.listCollections();
129
-        if (listResponse != null) {
130
-            List<String> collectionNames = listResponse.getCollectionNames();
131
-            for (String collectionName : collectionNames) {
132
-                JSONObject jsonObject = new JSONObject();
133
-                DescribeCollectionReq request = DescribeCollectionReq.builder()
134
-                        .collectionName(collectionName)
135
-                        .build();
136
-                DescribeCollectionResp describeResponse = milvusClient.describeCollection(request);
137
-                jsonObject.put("collectionId", describeResponse.getCollectionID());
138
-                jsonObject.put("collectionName", collectionName);
139
-                SimpleDateFormat beijingFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
140
-                beijingFormat.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
141
-                String beijingTime = beijingFormat.format(describeResponse.getCreateUtcTime());
142
-                jsonObject.put("createdTime", beijingTime);
143
-                jsonObject.put("description", describeResponse.getDescription());
144
-                jsonArray.add(jsonObject);
145
-            }
146
-        }
147
-        return jsonArray;
148
-    }
149
-
150
-    /**
151
-     * 根据名称查询知识库Collection
152
-     * 返回指定名称的集合信息
153
-     */
154
-    @Override
155
-    public JSONArray listKnowLedgeByCollectionName(String collectionName) {
156
-        JSONArray jsonArray = new JSONArray();
157
-        ListCollectionsResp listResponse = milvusClient.listCollections();
158
-
159
-        if (listResponse != null) {
160
-            List<String> allCollectionNames = listResponse.getCollectionNames();
161
-            for (String name : allCollectionNames) {
162
-                JSONObject jsonObject = new JSONObject();
163
-                DescribeCollectionReq request = DescribeCollectionReq.builder()
164
-                        .collectionName(name)
165
-                        .build();
166
-                DescribeCollectionResp describeResponse = milvusClient.describeCollection(request);
167
-                if (describeResponse.getDescription().contains(collectionName)) {
168
-                    jsonObject.put("collectionId", describeResponse.getCollectionID());
169
-                    jsonObject.put("collectionName", name);
170
-                    SimpleDateFormat beijingFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
171
-                    beijingFormat.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
172
-                    String beijingTime = beijingFormat.format(describeResponse.getCreateUtcTime());
173
-                    jsonObject.put("createdTime", beijingTime);
174
-                    jsonObject.put("description", describeResponse.getDescription());
175
-                    jsonArray.add(jsonObject);
176
-                }
177
-            }
178
-        }
179
-        return jsonArray;
180
-    }
181
-
182
-    /**
183
-     * 修改知识库Collection
184
-     */
185
-    @Override
186
-    public void collectionRename(String collectionName, String newCollectionName) {
187
-        RenameCollectionReq renameCollectionReq = RenameCollectionReq.builder()
188
-                .collectionName(collectionName)
189
-                .newCollectionName(newCollectionName)
190
-                .build();
191
-
192
-        milvusClient.renameCollection(renameCollectionReq);
193
-    }
194
-
195
-    /**
196
-     * 删除知识库Collection
197
-     */
198
-    @Override
199
-    public void deleteCollectionName(String collectionName) {
200
-        DropCollectionReq dropCollectionReq = DropCollectionReq.builder()
201
-                .collectionName(collectionName)
202
-                .build();
203
-        milvusClient.dropCollection(dropCollectionReq);
204
-    }
205
-
206
-    /**
207
-     * 查询知识库文件
208
-     */
209
-    @Override
210
-    public List<String> listDocument(String collectionName, String fileType) {
211
-        List<String> documentList = new ArrayList<>();
212
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
213
-                .collectionName(collectionName)
214
-                .build();
215
-        milvusClient.loadCollection(loadCollectionReq);
216
-        QueryReq queryParam = QueryReq.builder()
217
-                .collectionName(collectionName)
218
-                .filter("id > 0")
219
-                .outputFields(Arrays.asList("file_type", "file_name"))
220
-                .build();
221
-        if (fileType != null && !fileType.equals(""))
222
-            queryParam = QueryReq.builder()
223
-                    .collectionName(collectionName)
224
-                    .filter(String.format("file_type == \"%s\"", fileType))
225
-                    .outputFields(Arrays.asList("file_type", "file_name"))
226
-                    .build();
227
-        QueryResp queryResp = milvusClient.query(queryParam);
228
-        List<QueryResp.QueryResult> rowRecordList;
229
-        if (queryResp != null) {
230
-            rowRecordList = queryResp.getQueryResults();
231
-            for (QueryResp.QueryResult rowRecord : rowRecordList) {
232
-                documentList.add(rowRecord.getEntity().get("file_name").toString());
233
-            }
234
-        }
235
-        ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
236
-                .collectionName(collectionName)
237
-                .build();
238
-        milvusClient.releaseCollection(releaseCollectionReq);
239
-        return documentList.stream().distinct().collect(Collectors.toList());
240
-    }
241
-
242
-    /**
243
-     * 删除知识库文件
244
-     */
245
-    @Override
246
-    public void removeDocument(String collectionName, String fileName) {
247
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
248
-                .collectionName(collectionName)
249
-                .build();
250
-        milvusClient.loadCollection(loadCollectionReq);
251
-        DeleteReq deleteReq = DeleteReq.builder()
252
-                .collectionName(collectionName)
253
-                .filter(String.format("file_name == \"%s\"", fileName))
254
-                .build();
255
-        milvusClient.delete(deleteReq);
256
-    }
257
-
258
-    /**
259
-     * 删除知识库所有文件
260
-     */
261
-    @Override
262
-    public void removeAllDocument(String collectionName) {
263
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
264
-                .collectionName(collectionName)
265
-                .build();
266
-        milvusClient.loadCollection(loadCollectionReq);
267
-        DeleteReq deleteReq = DeleteReq.builder()
268
-                .collectionName(collectionName)
269
-                .filter("id > 0")
270
-                .build();
271
-        milvusClient.delete(deleteReq);
272
-    }
273
-
274
-    /**
275
-     * 列出所有的title
276
-     */
277
-    @Override
278
-    public List<String> listTiles(String collectionName) {
279
-        List<String> titleList = new ArrayList<>();
280
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
281
-                .collectionName(collectionName)
282
-                .build();
283
-        milvusClient.loadCollection(loadCollectionReq);
284
-        QueryReq queryParam = QueryReq.builder()
285
-                .collectionName(collectionName)
286
-                .filter("id > 0 && title != \"\"")
287
-                .outputFields(Arrays.asList("title"))
288
-                .build();
289
-        QueryResp queryResp = milvusClient.query(queryParam);
290
-        List<QueryResp.QueryResult> rowRecordList;
291
-        if (queryResp != null) {
292
-            rowRecordList = queryResp.getQueryResults();
293
-            for (QueryResp.QueryResult rowRecord : rowRecordList) {
294
-                Object title = rowRecord.getEntity().get("title");
295
-                if (title != null) {
296
-                    titleList.add(title.toString());
297
-                }
298
-            }
299
-        }
300
-        ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
301
-                .collectionName(collectionName)
302
-                .build();
303
-        milvusClient.releaseCollection(releaseCollectionReq);
304
-        return titleList.stream().distinct().collect(Collectors.toList());
305
-    }
306
-
307
-    /**
308
-     * 根据文件名获取标题列表
309
-     */
310
-    @Override
311
-    public List<String> listTilesByFile(String collectionName, String fileName) {
312
-        List<String> titleList = new ArrayList<>();
313
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
314
-                .collectionName(collectionName)
315
-                .build();
316
-        milvusClient.loadCollection(loadCollectionReq);
317
-        QueryReq queryParam = QueryReq.builder()
318
-                .collectionName(collectionName)
319
-                .filter(String.format("id > 0 && title != \"\" && file_name == \"%s\"", fileName))
320
-                .outputFields(Arrays.asList("title"))
321
-                .build();
322
-        QueryResp queryResp = milvusClient.query(queryParam);
323
-        List<QueryResp.QueryResult> rowRecordList;
324
-        if (queryResp != null) {
325
-            rowRecordList = queryResp.getQueryResults();
326
-            for (QueryResp.QueryResult rowRecord : rowRecordList) {
327
-                Object title = rowRecord.getEntity().get("title");
328
-                if (title != null) {
329
-                    titleList.add(title.toString());
330
-                }
331
-            }
332
-        }
333
-        ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
334
-                .collectionName(collectionName)
335
-                .build();
336
-        milvusClient.releaseCollection(releaseCollectionReq);
337
-        return titleList.stream().distinct().collect(Collectors.toList());
338
-    }
339
-
340
-    /**
341
-     * 根据title名查询相关content
342
-     */
343
-    @Override
344
-    public JSONArray listByTitle(String collectionName, String title) {
345
-        JSONArray resultArray = new JSONArray();
346
-        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
347
-                .collectionName(collectionName)
348
-                .build();
349
-        milvusClient.loadCollection(loadCollectionReq);
350
-        QueryReq queryParam = QueryReq.builder()
351
-                .collectionName(collectionName)
352
-                .filter(String.format("title == \"%s\"", title))
353
-                .outputFields(Arrays.asList("content", "file_name"))
354
-                .build();
355
-        QueryResp queryResp = milvusClient.query(queryParam);
356
-        List<QueryResp.QueryResult> rowRecordList;
357
-        if (queryResp != null) {
358
-            rowRecordList = queryResp.getQueryResults();
359
-            for (QueryResp.QueryResult rowRecord : rowRecordList) {
360
-                JSONObject item = new JSONObject();
361
-                Object content = rowRecord.getEntity().get("content");
362
-                Object fileName = rowRecord.getEntity().get("file_name");
363
-                if (content != null) {
364
-                    item.put("content", content.toString());
365
-                }
366
-                if (fileName != null) {
367
-                    item.put("file_name", fileName.toString());
368
-                }
369
-                resultArray.add(item);
370
-            }
371
-        }
372
-        ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
373
-                .collectionName(collectionName)
374
-                .build();
375
-        milvusClient.releaseCollection(releaseCollectionReq);
376
-        return resultArray;
377
-    }
378
-
379
-}

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

@@ -1,274 +0,0 @@
1
-package com.ruoyi.web.llm.service.impl;
2
-
3
-import com.alibaba.fastjson2.JSONObject;
4
-import com.ruoyi.llm.domain.CmcChat;
5
-import com.ruoyi.llm.service.ICmcChatService;
6
-import com.ruoyi.web.llm.service.ILangChainMilvusService;
7
-import com.ruoyi.web.llm.service.ISessionService;
8
-import org.noear.solon.ai.chat.ChatModel;
9
-import org.noear.solon.ai.chat.ChatResponse;
10
-import org.noear.solon.ai.chat.ChatSession;
11
-import org.noear.solon.ai.chat.message.ChatMessage;
12
-import org.noear.solon.ai.chat.prompt.Prompt;
13
-import org.noear.solon.ai.chat.session.InMemoryChatSession;
14
-import org.noear.solon.ai.mcp.McpChannel;
15
-import org.noear.solon.ai.mcp.client.McpClientProvider;
16
-import org.springframework.beans.factory.annotation.Autowired;
17
-import org.springframework.beans.factory.annotation.Value;
18
-import org.springframework.stereotype.Service;
19
-import reactor.core.publisher.Flux;
20
-
21
-import java.util.ArrayList;
22
-import java.util.List;
23
-
24
-@Service
25
-public class SessionServiceImpl implements ISessionService {
26
-
27
-    @Autowired
28
-    private ICmcChatService cmcChatService;
29
-
30
-    @Autowired
31
-    private ILangChainMilvusService langChainMilvusService;
32
-
33
-    @Value("${cmc.llmService.url}")
34
-    private String llmServiceUrl;
35
-
36
-    @Value("${cmc.mcpService.url}")
37
-    private String mcpServiceUrl;
38
-
39
-    @Override
40
-    public Flux<String> answer(String topicId, String question) {
41
-        McpClientProvider clientProvider = McpClientProvider.builder()
42
-                .channel(McpChannel.STREAMABLE_STATELESS)
43
-                .url(mcpServiceUrl)
44
-                .build();
45
-        ChatModel chatModel = ChatModel.of(llmServiceUrl)
46
-                .model("Qwen")
47
-                .defaultToolAdd(clientProvider)
48
-                .build();
49
-
50
-        List<ChatMessage> messages = new ArrayList<>();
51
-        CmcChat cmcChat = new CmcChat();
52
-        cmcChat.setTopicId(topicId);
53
-        List<CmcChat> cmcChatList = cmcChatService.selectCmcChatList(cmcChat);
54
-        for (CmcChat chat : cmcChatList) {
55
-            messages.add(ChatMessage.ofUser(chat.getInput()));
56
-            messages.add(ChatMessage.ofAssistant(chat.getOutput()));
57
-        }
58
-
59
-        // 第一步:调用 GetAllTableNames 获取所有表名
60
-        String step1Prompt = "你是一个MySQL专家。请调用 GetAllTableNames 工具获取数据库中所有表名列表。";
61
-        messages.add(ChatMessage.ofUser(step1Prompt));
62
-        ChatSession session1 = InMemoryChatSession.builder().messages(messages).build();
63
-        Prompt prompt1 = Prompt.of(step1Prompt).attrPut("session", session1);
64
-
65
-        Flux<ChatResponse> chatResponse = chatModel.prompt(prompt1).stream();
66
-        Flux<String> contentFlux = chatResponse
67
-                .concatMap(resp -> {
68
-                    try {
69
-                        // 执行第一步:获取所有表名
70
-                        String toolCall1 = "{\"name\": \"GetAllTableNames\", \"arguments\": {}}";
71
-                        String toolResult1 = clientProvider.callTool("GetAllTableNames", new JSONObject()).getContent();
72
-
73
-                        // 第二步:模型分析表名,找到相关表
74
-                        List<ChatMessage> messages2 = new ArrayList<>(messages);
75
-                        messages2.add(ChatMessage.ofAssistant("<tool_call>" + toolCall1 + "</tool_call>"));
76
-                        messages2.add(ChatMessage.ofUser(toolResult1));
77
-
78
-                        String step2Prompt = "请分析以下表名列表,找出与" + question + "相关的表。\n\n" +
79
-                        "**输出格式要求**:\n" +
80
-                        "- 如果需要数据库查询,请以固定格式输出:以\"相关表:\" 开头,中间用英文逗号隔开,以英文句号结尾\n" +
81
-                        "- 如果不需要进行数据库查询,请直接回答。\n\n" + toolResult1;
82
-                        messages2.add(ChatMessage.ofUser(step2Prompt));
83
-                        ChatSession session2 = InMemoryChatSession.builder().messages(messages2).build();
84
-
85
-                        // 生成第二步的响应
86
-                        return chatModel.prompt(Prompt.of(step2Prompt).attrPut("session", session2))
87
-                                .stream()
88
-                                .concatMap(resp1 -> {
89
-                                    try {
90
-                                        String content2 = resp1.getContent();
91
-
92
-                                        // 提取相关表名(从模型响应中提取)
93
-                                        String relevantTables = extractRelevantTables(content2);
94
-
95
-                                        // 检查是否需要继续执行(如果包含工具调用或明确需要数据库查询)
96
-                                        if (relevantTables.equals("")) {
97
-                                            // 不需要数据库查询,直接返回最终回答
98
-                                            return Flux.just(content2)
99
-                                                    .map(this::toSseDataFrame)
100
-                                                    .concatWith(Flux.just("data: [DONE]\n\n"));
101
-                                        }
102
-
103
-                                        // 第三步:调用 GetTableStructure 获取相关表的结构
104
-                                        List<ChatMessage> messages3 = new ArrayList<>(messages2);
105
-                                        messages3.add(ChatMessage.ofAssistant(content2));
106
-
107
-                                        // 执行第三步:获取表结构
108
-                                        StringBuilder tableStructures = new StringBuilder();
109
-                                        for (String tableName : relevantTables.split(",")) {
110
-                                            tableName = tableName.trim();
111
-                                            if (!tableName.isEmpty()) {
112
-                                                JSONObject args = new JSONObject();
113
-                                                args.put("tableName", tableName);
114
-                                                String toolResult3 = clientProvider.callTool("GetTableStructure", args)
115
-                                                        .getContent();
116
-                                                tableStructures.append("表:").append(tableName).append("\n")
117
-                                                        .append(toolResult3)
118
-                                                        .append("\n\n");
119
-                                            }
120
-                                        }
121
-
122
-                                        // 第四步:模型生成 SQL 查询
123
-                                        List<ChatMessage> messages4 = new ArrayList<>(messages3);
124
-                                        messages4.add(ChatMessage.ofAssistant(
125
-                                                "<tool_call>{\"name\": \"GetTableStructure\", \"arguments\": {\"tableName\": \"employee\"}}</tool_call>"));
126
-                                        messages4.add(ChatMessage.ofUser(tableStructures.toString()));
127
-
128
-                                        String step4Prompt = "请根据以下表结构,生成" + question + "的SQL语句:\n\n"
129
-                                                + tableStructures.toString();
130
-                                        messages4.add(ChatMessage.ofUser(step4Prompt));
131
-
132
-                                        // 生成第四步的响应
133
-                                        return chatModel
134
-                                                .prompt(Prompt.of(step4Prompt).attrPut("session",
135
-                                                        InMemoryChatSession.builder().messages(messages4).build()))
136
-                                                .stream()
137
-                                                .concatMap(resp2 -> {
138
-                                                    try {
139
-                                                        String content4 = resp2.getContent();
140
-
141
-                                                        // 提取 SQL 语句
142
-                                                        String sqlQuery = extractSqlFromContent(content4);
143
-
144
-                                                        // 第五步:调用 SQLQuery 执行查询
145
-                                                        List<ChatMessage> messages5 = new ArrayList<>(messages4);
146
-                                                        messages5.add(ChatMessage.ofAssistant(content4));
147
-
148
-                                                        // 执行第五步:执行 SQL 查询
149
-                                                        JSONObject sqlArgs = new JSONObject();
150
-                                                        sqlArgs.put("sqlString", sqlQuery);
151
-                                                        String toolResult5 = clientProvider
152
-                                                                .callTool("SQLQuery", sqlArgs)
153
-                                                                .getContent();
154
-
155
-                                                        // 第六步:模型生成最终回答
156
-                                                        List<ChatMessage> messages6 = new ArrayList<>(messages5);
157
-                                                        messages6.add(ChatMessage.ofAssistant(
158
-                                                                "<tool_call>{\"name\": \"SQLQuery\", \"arguments\": {\"sqlString\": \""
159
-                                                                        + sqlQuery + "\"}}</tool_call>"));
160
-                                                        messages6.add(ChatMessage.ofUser(toolResult5));
161
-
162
-                                                        String step6Prompt = "请根据以下查询结果,生成关于" + question + "的最终回答:\n\n"
163
-                                                                + toolResult5;
164
-                                                        messages6.add(ChatMessage.ofUser(step6Prompt));
165
-
166
-                                                        // 生成第六步的响应(流式)
167
-                                                        return chatModel
168
-                                                                .prompt(Prompt.of(step6Prompt).attrPut("session",
169
-                                                                        InMemoryChatSession.builder()
170
-                                                                                .messages(messages6)
171
-                                                                                .build()))
172
-                                                                .stream()
173
-                                                                .map(resp3 -> resp3.getContent())
174
-                                                                .filter(answer -> answer != null && !answer.isEmpty());
175
-                                                    } catch (Exception e) {
176
-                                                        return Flux.just("抱歉,系统执行查询时遇到问题,请稍后重试。")
177
-                                                                .map(this::toSseDataFrame)
178
-                                                                .concatWith(Flux.just("data: [DONE]\n\n"));
179
-                                                    }
180
-                                                });
181
-                                    } catch (Exception e) {
182
-                                        return Flux.just("抱歉,系统执行查询时遇到问题,请稍后重试。")
183
-                                                .map(this::toSseDataFrame)
184
-                                                .concatWith(Flux.just("data: [DONE]\n\n"));
185
-                                    }
186
-                                });
187
-                    } catch (Exception e) {
188
-                        return Flux.just("抱歉,系统执行查询时遇到问题,请稍后重试。")
189
-                                .map(this::toSseDataFrame)
190
-                                .concatWith(Flux.just("data: [DONE]\n\n"));
191
-                    }
192
-                });
193
-        // 手工拼接 SSE 帧,绕过 ServerSentEvent 编码差异;并在末尾发送 [DONE]
194
-        Flux<String> sseFlux = contentFlux
195
-                .map(this::toSseDataFrame)
196
-                .concatWith(Flux.just("data: [DONE]\n\n"));
197
-        return sseFlux;
198
-    }
199
-
200
-    /**
201
-     * 从模型响应中提取相关表名
202
-     */
203
-    private String extractRelevantTables(String content) {
204
-        // 简化处理,实际应该根据模型输出格式提取
205
-        // 假设模型会返回类似 "相关表:employee, department"
206
-        if (content.contains("相关表:")) {
207
-            int start = content.indexOf("相关表:") + 4;
208
-            int end = content.indexOf(".", start);
209
-            if (end > start) {
210
-                String tables = content.substring(start, end).trim();
211
-                // 限制相关表最多只保留10个
212
-                String[] tableNames = tables.split(",");
213
-                if (tableNames.length > 10) {
214
-                    StringBuilder limitedTables = new StringBuilder();
215
-                    for (int i = 0; i < 10; i++) {
216
-                        if (i > 0) {
217
-                            limitedTables.append(",");
218
-                        }
219
-                        limitedTables.append(tableNames[i].trim());
220
-                    }
221
-                    return limitedTables.toString();
222
-                }
223
-                return tables;
224
-            }
225
-        }
226
-        // 默认返回可能相关的表
227
-        return "";
228
-    }
229
-
230
-    /**
231
-     * 从模型响应中提取 SQL 语句
232
-     */
233
-    private String extractSqlFromContent(String content) {
234
-        // 简化处理,实际应该根据模型输出格式提取
235
-        if (content.contains("SELECT")) {
236
-            int start = content.indexOf("SELECT");
237
-            int end = content.indexOf(";", start);
238
-            if (end > start) {
239
-                return content.substring(start, end + 1);
240
-            }
241
-        }
242
-        return "";
243
-    }
244
-
245
-    @Override
246
-    public Flux<String> answerWithDocument(String topicId, String chatId, String question) throws Exception {
247
-        return langChainMilvusService.generateAnswerWithDocument(topicId, chatId, question);
248
-    }
249
-
250
-    private String toSseDataFrame(String data) {
251
-        if (data == null) {
252
-            return "data: \n\n";
253
-        }
254
-        // SSE 要求每一行都以 data: 开头
255
-        String normalized = data.replace("\r\n", "\n");
256
-        StringBuilder sb = new StringBuilder(normalized.length() + 16);
257
-        sb.append("data: ");
258
-        int start = 0;
259
-        while (true) {
260
-            int idx = normalized.indexOf('\n', start);
261
-            if (idx < 0) {
262
-                sb.append(normalized.substring(start));
263
-                break;
264
-            }
265
-            sb.append(normalized, start, idx);
266
-            sb.append("\n");
267
-            sb.append("data: ");
268
-            start = idx + 1;
269
-        }
270
-        sb.append("\n\n");
271
-        return sb.toString();
272
-    }
273
-
274
-}

+ 123
- 0
oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/domain/AnswerStreamResponse.java Просмотреть файл

@@ -0,0 +1,123 @@
1
+package com.ruoyi.llm.domain;
2
+
3
+/**
4
+ * 回答流式响应
5
+ */
6
+public class AnswerStreamResponse {
7
+
8
+    /**
9
+     * 回答内容
10
+     */
11
+    private String content;
12
+
13
+    /**
14
+     * 是否是最后一块内容
15
+     */
16
+    private boolean isLast;
17
+
18
+    /**
19
+     * 是否发生错误
20
+     */
21
+    private boolean error;
22
+
23
+    /**
24
+     * 错误信息
25
+     */
26
+    private String errorMessage;
27
+
28
+    /**
29
+     * 提示词token数量
30
+     */
31
+    private long promptTokens;
32
+
33
+    /**
34
+     * 完成token数量
35
+     */
36
+    private long completionTokens;
37
+
38
+    /**
39
+     * 总token数量
40
+     */
41
+    private long totalTokens;
42
+
43
+    public AnswerStreamResponse() {
44
+    }
45
+
46
+    /**
47
+     * 创建内容响应
48
+     */
49
+    public static AnswerStreamResponse content(String content, boolean isLast) {
50
+        AnswerStreamResponse response = new AnswerStreamResponse();
51
+        response.content = content;
52
+        response.isLast = isLast;
53
+        response.error = false;
54
+        return response;
55
+    }
56
+
57
+    /**
58
+     * 创建错误响应
59
+     */
60
+    public static AnswerStreamResponse error(String errorMessage) {
61
+        AnswerStreamResponse response = new AnswerStreamResponse();
62
+        response.error = true;
63
+        response.errorMessage = errorMessage;
64
+        return response;
65
+    }
66
+
67
+    // Getters and Setters
68
+    public String getContent() {
69
+        return content;
70
+    }
71
+
72
+    public void setContent(String content) {
73
+        this.content = content;
74
+    }
75
+
76
+    public boolean isLast() {
77
+        return isLast;
78
+    }
79
+
80
+    public void setLast(boolean last) {
81
+        isLast = last;
82
+    }
83
+
84
+    public boolean isError() {
85
+        return error;
86
+    }
87
+
88
+    public void setError(boolean error) {
89
+        this.error = error;
90
+    }
91
+
92
+    public String getErrorMessage() {
93
+        return errorMessage;
94
+    }
95
+
96
+    public void setErrorMessage(String errorMessage) {
97
+        this.errorMessage = errorMessage;
98
+    }
99
+
100
+    public long getPromptTokens() {
101
+        return promptTokens;
102
+    }
103
+
104
+    public void setPromptTokens(long promptTokens) {
105
+        this.promptTokens = promptTokens;
106
+    }
107
+
108
+    public long getCompletionTokens() {
109
+        return completionTokens;
110
+    }
111
+
112
+    public void setCompletionTokens(long completionTokens) {
113
+        this.completionTokens = completionTokens;
114
+    }
115
+
116
+    public long getTotalTokens() {
117
+        return totalTokens;
118
+    }
119
+
120
+    public void setTotalTokens(long totalTokens) {
121
+        this.totalTokens = totalTokens;
122
+    }
123
+}

+ 47
- 0
oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/domain/ChapterStreamResponse.java Просмотреть файл

@@ -45,6 +45,21 @@ public class ChapterStreamResponse {
45 45
      */
46 46
     private boolean completed;
47 47
 
48
+    /**
49
+     * 提示词token数量
50
+     */
51
+    private long promptTokens;
52
+
53
+    /**
54
+     * 完成token数量
55
+     */
56
+    private long completionTokens;
57
+
58
+    /**
59
+     * 总token数量
60
+     */
61
+    private long totalTokens;
62
+
48 63
     public ChapterStreamResponse() {
49 64
     }
50 65
 
@@ -78,6 +93,14 @@ public class ChapterStreamResponse {
78 93
         return response;
79 94
     }
80 95
 
96
+    public static ChapterStreamResponse progress(String progressMessage) {
97
+        ChapterStreamResponse response = new ChapterStreamResponse();
98
+        response.content = progressMessage;
99
+        response.completed = false;
100
+        response.isLast = false;
101
+        return response;
102
+    }
103
+
81 104
     // Getters and Setters
82 105
     public String getTitle() {
83 106
         return title;
@@ -143,6 +166,30 @@ public class ChapterStreamResponse {
143 166
         this.completed = completed;
144 167
     }
145 168
 
169
+    public long getPromptTokens() {
170
+        return promptTokens;
171
+    }
172
+
173
+    public void setPromptTokens(long promptTokens) {
174
+        this.promptTokens = promptTokens;
175
+    }
176
+
177
+    public long getCompletionTokens() {
178
+        return completionTokens;
179
+    }
180
+
181
+    public void setCompletionTokens(long completionTokens) {
182
+        this.completionTokens = completionTokens;
183
+    }
184
+
185
+    public long getTotalTokens() {
186
+        return totalTokens;
187
+    }
188
+
189
+    public void setTotalTokens(long totalTokens) {
190
+        this.totalTokens = totalTokens;
191
+    }
192
+
146 193
     @Override
147 194
     public String toString() {
148 195
         return "ChapterStreamResponse{" +

oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/ILangChainMilvusService.java → oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/ILangChainMilvusService.java Просмотреть файл

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

oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/IMilvusService.java → oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/IMilvusService.java Просмотреть файл

@@ -1,4 +1,4 @@
1
-package com.ruoyi.web.llm.service;
1
+package com.ruoyi.llm.service;
2 2
 
3 3
 import com.alibaba.fastjson2.JSONArray;
4 4
 

+ 17
- 0
oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/ISessionService.java Просмотреть файл

@@ -0,0 +1,17 @@
1
+package com.ruoyi.llm.service;
2
+
3
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
4
+
5
+public interface ISessionService {
6
+
7
+    /**
8
+     * 生成回答
9
+     */
10
+    SseEmitter answer(String topicId, String question);
11
+
12
+    /**
13
+     * 调用LLM+RAG(外部文件)生成回答
14
+     */
15
+    SseEmitter answerWithDocument(String topicId, String chatId, String question) throws Exception;
16
+
17
+}

+ 588
- 386
oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/CmcAgentServiceImpl.java
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 1258
- 0
oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/LangChainMilvusServiceImpl.java
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


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

@@ -0,0 +1,470 @@
1
+package com.ruoyi.llm.service.impl;
2
+
3
+import com.alibaba.fastjson2.JSONArray;
4
+import com.alibaba.fastjson2.JSONObject;
5
+import com.ruoyi.common.utils.milvus.MilvusConnectionPool;
6
+import com.ruoyi.llm.service.IMilvusService;
7
+import io.milvus.v2.client.MilvusClientV2;
8
+import io.milvus.v2.common.DataType;
9
+import io.milvus.v2.common.IndexParam;
10
+import io.milvus.v2.service.collection.request.*;
11
+import io.milvus.v2.service.collection.response.DescribeCollectionResp;
12
+import io.milvus.v2.service.collection.response.ListCollectionsResp;
13
+import io.milvus.v2.service.vector.request.DeleteReq;
14
+import io.milvus.v2.service.vector.request.QueryReq;
15
+import io.milvus.v2.service.vector.response.QueryResp;
16
+import org.springframework.beans.factory.annotation.Autowired;
17
+import org.springframework.stereotype.Service;
18
+
19
+import java.text.SimpleDateFormat;
20
+import java.util.*;
21
+import java.util.stream.Collectors;
22
+
23
+@Service
24
+public class MilvusServiceImpl implements IMilvusService {
25
+
26
+    @Autowired
27
+    private MilvusConnectionPool milvusConnectionPool;
28
+
29
+    /**
30
+     * 新建知识库Collection(含Schema、Field、Index)
31
+     */
32
+    @Override
33
+    public void createCollection(String collectionName, String description, int dimension) {
34
+        MilvusClientV2 milvusClient = null;
35
+        try {
36
+            milvusClient = milvusConnectionPool.getClient();
37
+            CreateCollectionReq.CollectionSchema schema = MilvusClientV2.CreateSchema();
38
+
39
+            schema.addField(AddFieldReq.builder()
40
+                    .fieldName("id")
41
+                    .dataType(DataType.Int64)
42
+                    .isPrimaryKey(true)
43
+                    .autoID(true)
44
+                    .build());
45
+
46
+            schema.addField(AddFieldReq.builder()
47
+                    .fieldName("file_name")
48
+                    .dataType(DataType.VarChar)
49
+                    .maxLength(256)
50
+                    .build());
51
+
52
+            schema.addField(AddFieldReq.builder()
53
+                    .fieldName("title")
54
+                    .dataType(DataType.VarChar)
55
+                    .maxLength(256)
56
+                    .build());
57
+
58
+            schema.addField(AddFieldReq.builder()
59
+                    .fieldName("file_type")
60
+                    .dataType(DataType.VarChar)
61
+                    .maxLength(10)
62
+                    .build());
63
+
64
+            schema.addField(AddFieldReq.builder()
65
+                    .fieldName("content")
66
+                    .dataType(DataType.VarChar)
67
+                    .maxLength(65535)
68
+                    .build());
69
+
70
+            schema.addField(AddFieldReq.builder()
71
+                    .fieldName("embedding")
72
+                    .dataType(DataType.FloatVector)
73
+                    .dimension(dimension)
74
+                    .build());
75
+
76
+            // 创建索引
77
+            Map<String, Object> extraParams = new HashMap<>();
78
+            extraParams.put("nlist", 64);
79
+            IndexParam indexParam = IndexParam.builder()
80
+                    .fieldName("embedding")
81
+                    .indexType(IndexParam.IndexType.IVF_FLAT)
82
+                    .metricType(IndexParam.MetricType.COSINE)
83
+                    .extraParams(extraParams)
84
+                    .build();
85
+
86
+            CreateCollectionReq createCollectionParam = CreateCollectionReq.builder()
87
+                    .collectionName(collectionName)
88
+                    .description(description)
89
+                    .collectionSchema(schema)
90
+                    .indexParam(indexParam)
91
+                    .build();
92
+
93
+            milvusClient.createCollection(createCollectionParam);
94
+        } catch (InterruptedException e) {
95
+            Thread.currentThread().interrupt();
96
+            throw new RuntimeException("获取Milvus连接失败", e);
97
+        } finally {
98
+            if (milvusClient != null) {
99
+                milvusConnectionPool.returnClient(milvusClient);
100
+            }
101
+        }
102
+    }
103
+
104
+    /**
105
+     * 查询知识库Collection
106
+     */
107
+    @Override
108
+    public JSONArray getCollectionNames() {
109
+        MilvusClientV2 milvusClient = null;
110
+        try {
111
+            milvusClient = milvusConnectionPool.getClient();
112
+            JSONArray jsonArray = new JSONArray();
113
+            ListCollectionsResp listResponse = milvusClient.listCollections();
114
+            if (listResponse != null) {
115
+                List<String> collectionNames = listResponse.getCollectionNames();
116
+                for (String collectionName : collectionNames) {
117
+                    JSONObject jsonObject = new JSONObject();
118
+                    DescribeCollectionReq request = DescribeCollectionReq.builder()
119
+                            .collectionName(collectionName)
120
+                            .build();
121
+                    DescribeCollectionResp describeResponse = milvusClient.describeCollection(request);
122
+                    jsonObject.put("collectionId", describeResponse.getCollectionID());
123
+                    jsonObject.put("collectionName", collectionName);
124
+                    SimpleDateFormat beijingFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
125
+                    beijingFormat.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
126
+                    String beijingTime = beijingFormat.format(describeResponse.getCreateUtcTime());
127
+                    jsonObject.put("createdTime", beijingTime);
128
+                    jsonObject.put("description", describeResponse.getDescription());
129
+                    jsonArray.add(jsonObject);
130
+                }
131
+            }
132
+            return jsonArray;
133
+        } catch (InterruptedException e) {
134
+            Thread.currentThread().interrupt();
135
+            throw new RuntimeException("获取Milvus连接失败", e);
136
+        } finally {
137
+            if (milvusClient != null) {
138
+                milvusConnectionPool.returnClient(milvusClient);
139
+            }
140
+        }
141
+    }
142
+
143
+    /**
144
+     * 根据名称查询知识库Collection
145
+     * 返回指定名称的集合信息
146
+     */
147
+    @Override
148
+    public JSONArray listKnowLedgeByCollectionName(String collectionName) {
149
+        MilvusClientV2 milvusClient = null;
150
+        try {
151
+            milvusClient = milvusConnectionPool.getClient();
152
+            JSONArray jsonArray = new JSONArray();
153
+            ListCollectionsResp listResponse = milvusClient.listCollections();
154
+
155
+            if (listResponse != null) {
156
+                List<String> allCollectionNames = listResponse.getCollectionNames();
157
+                for (String name : allCollectionNames) {
158
+                    JSONObject jsonObject = new JSONObject();
159
+                    DescribeCollectionReq request = DescribeCollectionReq.builder()
160
+                            .collectionName(name)
161
+                            .build();
162
+                    DescribeCollectionResp describeResponse = milvusClient.describeCollection(request);
163
+                    if (describeResponse.getDescription().contains(collectionName)) {
164
+                        jsonObject.put("collectionId", describeResponse.getCollectionID());
165
+                        jsonObject.put("collectionName", name);
166
+                        SimpleDateFormat beijingFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
167
+                        beijingFormat.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
168
+                        String beijingTime = beijingFormat.format(describeResponse.getCreateUtcTime());
169
+                        jsonObject.put("createdTime", beijingTime);
170
+                        jsonObject.put("description", describeResponse.getDescription());
171
+                        jsonArray.add(jsonObject);
172
+                    }
173
+                }
174
+            }
175
+            return jsonArray;
176
+        } catch (InterruptedException e) {
177
+            Thread.currentThread().interrupt();
178
+            throw new RuntimeException("获取Milvus连接失败", e);
179
+        } finally {
180
+            if (milvusClient != null) {
181
+                milvusConnectionPool.returnClient(milvusClient);
182
+            }
183
+        }
184
+    }
185
+
186
+    /**
187
+     * 修改知识库Collection
188
+     */
189
+    @Override
190
+    public void collectionRename(String collectionName, String newCollectionName) {
191
+        MilvusClientV2 milvusClient = null;
192
+        try {
193
+            milvusClient = milvusConnectionPool.getClient();
194
+            RenameCollectionReq renameCollectionReq = RenameCollectionReq.builder()
195
+                    .collectionName(collectionName)
196
+                    .newCollectionName(newCollectionName)
197
+                    .build();
198
+
199
+            milvusClient.renameCollection(renameCollectionReq);
200
+        } catch (InterruptedException e) {
201
+            Thread.currentThread().interrupt();
202
+            throw new RuntimeException("获取Milvus连接失败", e);
203
+        } finally {
204
+            if (milvusClient != null) {
205
+                milvusConnectionPool.returnClient(milvusClient);
206
+            }
207
+        }
208
+    }
209
+
210
+    /**
211
+     * 删除知识库Collection
212
+     */
213
+    @Override
214
+    public void deleteCollectionName(String collectionName) {
215
+        MilvusClientV2 milvusClient = null;
216
+        try {
217
+            milvusClient = milvusConnectionPool.getClient();
218
+            DropCollectionReq dropCollectionReq = DropCollectionReq.builder()
219
+                    .collectionName(collectionName)
220
+                    .build();
221
+            milvusClient.dropCollection(dropCollectionReq);
222
+        } catch (InterruptedException e) {
223
+            Thread.currentThread().interrupt();
224
+            throw new RuntimeException("获取Milvus连接失败", e);
225
+        } finally {
226
+            if (milvusClient != null) {
227
+                milvusConnectionPool.returnClient(milvusClient);
228
+            }
229
+        }
230
+    }
231
+
232
+    /**
233
+     * 查询知识库文件
234
+     */
235
+    @Override
236
+    public List<String> listDocument(String collectionName, String fileType) {
237
+        MilvusClientV2 milvusClient = null;
238
+        try {
239
+            milvusClient = milvusConnectionPool.getClient();
240
+            List<String> documentList = new ArrayList<>();
241
+            LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
242
+                    .collectionName(collectionName)
243
+                    .build();
244
+            milvusClient.loadCollection(loadCollectionReq);
245
+            QueryReq queryParam = QueryReq.builder()
246
+                    .collectionName(collectionName)
247
+                    .filter("id > 0")
248
+                    .outputFields(Arrays.asList("file_type", "file_name"))
249
+                    .build();
250
+            if (fileType != null && !fileType.equals(""))
251
+                queryParam = QueryReq.builder()
252
+                        .collectionName(collectionName)
253
+                        .filter(String.format("file_type == \"%s\"", fileType))
254
+                        .outputFields(Arrays.asList("file_type", "file_name"))
255
+                        .build();
256
+            QueryResp queryResp = milvusClient.query(queryParam);
257
+            List<QueryResp.QueryResult> rowRecordList;
258
+            if (queryResp != null) {
259
+                rowRecordList = queryResp.getQueryResults();
260
+                for (QueryResp.QueryResult rowRecord : rowRecordList) {
261
+                    documentList.add(rowRecord.getEntity().get("file_name").toString());
262
+                }
263
+            }
264
+            ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
265
+                    .collectionName(collectionName)
266
+                    .build();
267
+            milvusClient.releaseCollection(releaseCollectionReq);
268
+            return documentList.stream().distinct().collect(Collectors.toList());
269
+        } catch (InterruptedException e) {
270
+            Thread.currentThread().interrupt();
271
+            throw new RuntimeException("获取Milvus连接失败", e);
272
+        } finally {
273
+            if (milvusClient != null) {
274
+                milvusConnectionPool.returnClient(milvusClient);
275
+            }
276
+        }
277
+    }
278
+
279
+    /**
280
+     * 删除知识库文件
281
+     */
282
+    @Override
283
+    public void removeDocument(String collectionName, String fileName) {
284
+        MilvusClientV2 milvusClient = null;
285
+        try {
286
+            milvusClient = milvusConnectionPool.getClient();
287
+            LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
288
+                    .collectionName(collectionName)
289
+                    .build();
290
+            milvusClient.loadCollection(loadCollectionReq);
291
+            DeleteReq deleteReq = DeleteReq.builder()
292
+                    .collectionName(collectionName)
293
+                    .filter(String.format("file_name == \"%s\"", fileName))
294
+                    .build();
295
+            milvusClient.delete(deleteReq);
296
+        } catch (InterruptedException e) {
297
+            Thread.currentThread().interrupt();
298
+            throw new RuntimeException("获取Milvus连接失败", e);
299
+        } finally {
300
+            if (milvusClient != null) {
301
+                milvusConnectionPool.returnClient(milvusClient);
302
+            }
303
+        }
304
+    }
305
+
306
+    /**
307
+     * 删除知识库所有文件
308
+     */
309
+    @Override
310
+    public void removeAllDocument(String collectionName) {
311
+        MilvusClientV2 milvusClient = null;
312
+        try {
313
+            milvusClient = milvusConnectionPool.getClient();
314
+            LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
315
+                    .collectionName(collectionName)
316
+                    .build();
317
+            milvusClient.loadCollection(loadCollectionReq);
318
+            DeleteReq deleteReq = DeleteReq.builder()
319
+                    .collectionName(collectionName)
320
+                    .filter("id > 0")
321
+                    .build();
322
+            milvusClient.delete(deleteReq);
323
+        } catch (InterruptedException e) {
324
+            Thread.currentThread().interrupt();
325
+            throw new RuntimeException("获取Milvus连接失败", e);
326
+        } finally {
327
+            if (milvusClient != null) {
328
+                milvusConnectionPool.returnClient(milvusClient);
329
+            }
330
+        }
331
+    }
332
+
333
+    /**
334
+     * 列出所有的title
335
+     */
336
+    @Override
337
+    public List<String> listTiles(String collectionName) {
338
+        MilvusClientV2 milvusClient = null;
339
+        try {
340
+            milvusClient = milvusConnectionPool.getClient();
341
+            List<String> titleList = new ArrayList<>();
342
+            LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
343
+                    .collectionName(collectionName)
344
+                    .build();
345
+            milvusClient.loadCollection(loadCollectionReq);
346
+            QueryReq queryParam = QueryReq.builder()
347
+                    .collectionName(collectionName)
348
+                    .filter("id > 0")
349
+                    .outputFields(Arrays.asList("title"))
350
+                    .build();
351
+            QueryResp queryResp = milvusClient.query(queryParam);
352
+            List<QueryResp.QueryResult> rowRecordList;
353
+            if (queryResp != null) {
354
+                rowRecordList = queryResp.getQueryResults();
355
+                for (QueryResp.QueryResult rowRecord : rowRecordList) {
356
+                    Object title = rowRecord.getEntity().get("title");
357
+                    if (title != null) {
358
+                        titleList.add(title.toString());
359
+                    }
360
+                }
361
+            }
362
+            ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
363
+                    .collectionName(collectionName)
364
+                    .build();
365
+            milvusClient.releaseCollection(releaseCollectionReq);
366
+            return titleList.stream().distinct().collect(Collectors.toList());
367
+        } catch (InterruptedException e) {
368
+            Thread.currentThread().interrupt();
369
+            throw new RuntimeException("获取Milvus连接失败", e);
370
+        } finally {
371
+            if (milvusClient != null) {
372
+                milvusConnectionPool.returnClient(milvusClient);
373
+            }
374
+        }
375
+    }
376
+
377
+    /**
378
+     * 根据文件名获取标题列表
379
+     */
380
+    @Override
381
+    public List<String> listTilesByFile(String collectionName, String fileName) {
382
+        MilvusClientV2 milvusClient = null;
383
+        try {
384
+            milvusClient = milvusConnectionPool.getClient();
385
+            List<String> titleList = new ArrayList<>();
386
+            LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
387
+                    .collectionName(collectionName)
388
+                    .build();
389
+            milvusClient.loadCollection(loadCollectionReq);
390
+            QueryReq queryParam = QueryReq.builder()
391
+                    .collectionName(collectionName)
392
+                    .filter(String.format("id > 0 && file_name == \"%s\"", fileName))
393
+                    .outputFields(Arrays.asList("title"))
394
+                    .build();
395
+            QueryResp queryResp = milvusClient.query(queryParam);
396
+            List<QueryResp.QueryResult> rowRecordList;
397
+            if (queryResp != null) {
398
+                rowRecordList = queryResp.getQueryResults();
399
+                for (QueryResp.QueryResult rowRecord : rowRecordList) {
400
+                    Object title = rowRecord.getEntity().get("title");
401
+                    if (title != null) {
402
+                        titleList.add(title.toString());
403
+                    }
404
+                }
405
+            }
406
+            ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
407
+                    .collectionName(collectionName)
408
+                    .build();
409
+            milvusClient.releaseCollection(releaseCollectionReq);
410
+            return titleList.stream().distinct().collect(Collectors.toList());
411
+        } catch (InterruptedException e) {
412
+            Thread.currentThread().interrupt();
413
+            throw new RuntimeException("获取Milvus连接失败", e);
414
+        } finally {
415
+            if (milvusClient != null) {
416
+                milvusConnectionPool.returnClient(milvusClient);
417
+            }
418
+        }
419
+    }
420
+
421
+    /**
422
+     * 根据title名查询相关content
423
+     */
424
+    @Override
425
+    public JSONArray listByTitle(String collectionName, String title) {
426
+        MilvusClientV2 milvusClient = null;
427
+        try {
428
+            milvusClient = milvusConnectionPool.getClient();
429
+            JSONArray resultArray = new JSONArray();
430
+            LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
431
+                    .collectionName(collectionName)
432
+                    .build();
433
+            milvusClient.loadCollection(loadCollectionReq);
434
+            QueryReq queryParam = QueryReq.builder()
435
+                    .collectionName(collectionName)
436
+                    .filter(String.format("title == \"%s\"", title))
437
+                    .outputFields(Arrays.asList("content", "file_name"))
438
+                    .build();
439
+            QueryResp queryResp = milvusClient.query(queryParam);
440
+            List<QueryResp.QueryResult> rowRecordList;
441
+            if (queryResp != null) {
442
+                rowRecordList = queryResp.getQueryResults();
443
+                for (QueryResp.QueryResult rowRecord : rowRecordList) {
444
+                    JSONObject item = new JSONObject();
445
+                    Object content = rowRecord.getEntity().get("content");
446
+                    Object fileName = rowRecord.getEntity().get("file_name");
447
+                    if (content != null) {
448
+                        item.put("content", content.toString());
449
+                    }
450
+                    if (fileName != null) {
451
+                        item.put("file_name", fileName.toString());
452
+                    }
453
+                    resultArray.add(item);
454
+                }
455
+            }
456
+            ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
457
+                    .collectionName(collectionName)
458
+                    .build();
459
+            milvusClient.releaseCollection(releaseCollectionReq);
460
+            return resultArray;
461
+        } catch (InterruptedException e) {
462
+            Thread.currentThread().interrupt();
463
+            throw new RuntimeException("获取Milvus连接失败", e);
464
+        } finally {
465
+            if (milvusClient != null) {
466
+                milvusConnectionPool.returnClient(milvusClient);
467
+            }
468
+        }
469
+    }
470
+}

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

@@ -0,0 +1,578 @@
1
+package com.ruoyi.llm.service.impl;
2
+
3
+import com.alibaba.fastjson2.JSONArray;
4
+import com.alibaba.fastjson2.JSONObject;
5
+import com.ruoyi.llm.domain.CmcChat;
6
+import com.ruoyi.llm.service.ICmcChatService;
7
+import com.ruoyi.llm.domain.AnswerStreamResponse;
8
+import com.ruoyi.llm.service.ILangChainMilvusService;
9
+import com.ruoyi.llm.service.ISessionService;
10
+import com.fasterxml.jackson.databind.ObjectMapper;
11
+import org.noear.solon.ai.chat.ChatModel;
12
+import org.noear.solon.ai.chat.message.ChatMessage;
13
+import org.noear.solon.ai.chat.prompt.Prompt;
14
+import org.noear.solon.ai.chat.session.InMemoryChatSession;
15
+import org.springframework.beans.factory.annotation.Autowired;
16
+import org.springframework.beans.factory.annotation.Value;
17
+import org.springframework.jdbc.core.JdbcTemplate;
18
+import org.springframework.stereotype.Service;
19
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
20
+
21
+import java.io.IOException;
22
+import java.sql.Connection;
23
+import java.sql.DatabaseMetaData;
24
+import java.sql.PreparedStatement;
25
+import java.sql.ResultSet;
26
+import java.sql.ResultSetMetaData;
27
+import java.sql.SQLException;
28
+import java.util.ArrayList;
29
+import java.util.List;
30
+import java.util.Map;
31
+import java.util.concurrent.*;
32
+import java.util.concurrent.atomic.AtomicBoolean;
33
+import java.util.concurrent.atomic.AtomicLong;
34
+
35
+@Service
36
+public class SessionServiceImpl implements ISessionService {
37
+
38
+    @Autowired
39
+    private ICmcChatService cmcChatService;
40
+
41
+    @Autowired
42
+    private ILangChainMilvusService langChainMilvusService;
43
+
44
+    @Autowired
45
+    private JdbcTemplate jdbcTemplate;
46
+
47
+    @Value("${cmc.llmService.url}")
48
+    private String llmServiceUrl;
49
+
50
+    /**
51
+     * 获取所有表名列表(只包含cmc、sys和files开头的表)
52
+     */
53
+    private String getAllTableNames() {
54
+        JSONArray tableNames = new JSONArray();
55
+        try {
56
+            String sql = "SELECT table_name, table_comment FROM information_schema.tables WHERE table_schema = DATABASE() "
57
+                    +
58
+                    "AND (table_name LIKE 'cmc%' OR table_name LIKE 'sys%' OR table_name LIKE 'files%')";
59
+            List<Map<String, Object>> tables = jdbcTemplate.queryForList(sql);
60
+
61
+            for (Map<String, Object> table : tables) {
62
+                JSONObject tableInfo = new JSONObject();
63
+                tableInfo.put("tableName", table.get("table_name"));
64
+                tableInfo.put("tableComment", table.get("table_comment") != null ? table.get("table_comment") : "");
65
+                tableNames.add(tableInfo);
66
+            }
67
+        } catch (Exception e) {
68
+            throw new RuntimeException("获取表名列表失败", e);
69
+        }
70
+        return tableNames.toJSONString();
71
+    }
72
+
73
+    /**
74
+     * 获取指定表的结构信息
75
+     */
76
+    private String getTableStructure(String tableName) {
77
+        JSONObject result = new JSONObject();
78
+
79
+        jdbcTemplate.execute((Connection conn) -> {
80
+            try {
81
+                DatabaseMetaData metaData = conn.getMetaData();
82
+
83
+                ResultSet columns = metaData.getColumns(null, null, tableName, null);
84
+                JSONArray columnsInfo = new JSONArray();
85
+
86
+                while (columns.next()) {
87
+                    JSONObject column = new JSONObject();
88
+                    column.put("columnName", columns.getString("COLUMN_NAME"));
89
+                    column.put("dataType", columns.getString("TYPE_NAME"));
90
+                    column.put("columnSize", columns.getString("COLUMN_SIZE"));
91
+                    column.put("isNullable", columns.getString("IS_NULLABLE"));
92
+                    column.put("columnComment", columns.getString("REMARKS"));
93
+                    columnsInfo.add(column);
94
+                }
95
+                columns.close();
96
+
97
+                ResultSet primaryKeys = metaData.getPrimaryKeys(null, null, tableName);
98
+                JSONArray primaryKeyColumns = new JSONArray();
99
+
100
+                while (primaryKeys.next()) {
101
+                    primaryKeyColumns.add(primaryKeys.getString("COLUMN_NAME"));
102
+                }
103
+                primaryKeys.close();
104
+
105
+                result.put("tableName", tableName);
106
+                result.put("columns", columnsInfo);
107
+                result.put("primaryKey", primaryKeyColumns);
108
+
109
+            } catch (SQLException e) {
110
+                throw new RuntimeException("获取表结构失败: " + tableName, e);
111
+            }
112
+            return null;
113
+        });
114
+
115
+        return result.toJSONString();
116
+    }
117
+
118
+    /**
119
+     * 执行SQL查询
120
+     */
121
+    private String executeSQL(String sqlString) {
122
+        String cleanedSqlString = sqlString.replace("[", "").replace("]", "");
123
+
124
+        JSONArray result = new JSONArray();
125
+
126
+        jdbcTemplate.execute((Connection conn) -> {
127
+            try (PreparedStatement stmt = conn.prepareStatement(cleanedSqlString)) {
128
+                ResultSet rs = stmt.executeQuery();
129
+                ResultSetMetaData metaData = rs.getMetaData();
130
+                int columnCount = metaData.getColumnCount();
131
+
132
+                while (rs.next()) {
133
+                    JSONObject row = new JSONObject();
134
+                    for (int i = 1; i <= columnCount; i++) {
135
+                        String columnName = metaData.getColumnLabel(i);
136
+                        Object value = rs.getObject(i);
137
+                        row.put(columnName, value);
138
+                    }
139
+                    result.add(row);
140
+                }
141
+                rs.close();
142
+            } catch (SQLException e) {
143
+                throw new RuntimeException("SQL执行失败", e);
144
+            }
145
+            return null;
146
+        });
147
+
148
+        return result.toJSONString();
149
+    }
150
+
151
+    @Override
152
+    public SseEmitter answer(String topicId, String question) {
153
+        // 设置SSE超时时间为10分钟
154
+        SseEmitter emitter = new SseEmitter(600000L);
155
+
156
+        // 设置回调
157
+        emitter.onCompletion(() -> {
158
+        });
159
+        emitter.onTimeout(() -> {
160
+            emitter.complete();
161
+        });
162
+        emitter.onError(e -> System.err.println("SSE连接错误: " + e.getMessage()));
163
+
164
+        // 使用独立线程池处理,避免阻塞主线程
165
+        ExecutorService executor = Executors.newSingleThreadExecutor();
166
+
167
+        executor.execute(() -> {
168
+            ObjectMapper objectMapper = new ObjectMapper();
169
+            try {
170
+                // 用于累计token用量
171
+                AtomicLong[] tokenUsage = new AtomicLong[3];
172
+                tokenUsage[0] = new AtomicLong(0); // promptTokens
173
+                tokenUsage[1] = new AtomicLong(0); // completionTokens
174
+                tokenUsage[2] = new AtomicLong(0); // totalTokens
175
+                System.out.println("llmServiceUrl: " + llmServiceUrl);
176
+
177
+                ChatModel chatModel = ChatModel.of(llmServiceUrl).model("Qwen")
178
+                        .build();
179
+                // ChatModel chatModel =
180
+                // ChatModel.of("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions")
181
+                // .model("qwen3-vl-32b-instruct")
182
+                // .apiKey("sk-750a17cc723847f28b31fa1bc17c255c")
183
+                // .build();
184
+
185
+                System.out.println("成功");
186
+                List<ChatMessage> messages = new ArrayList<>();
187
+                CmcChat cmcChat = new CmcChat();
188
+                cmcChat.setTopicId(topicId);
189
+                List<CmcChat> cmcChatList = cmcChatService.selectCmcChatList(cmcChat);
190
+                for (CmcChat chat : cmcChatList) {
191
+                    messages.add(ChatMessage.ofUser(chat.getInput()));
192
+                    messages.add(ChatMessage.ofAssistant(chat.getOutput()));
193
+                }
194
+
195
+                // 第一步:调用 getAllTableNames 获取所有表名(使用本地方法替代MCP)
196
+                String toolResult1 = getAllTableNames();
197
+                System.out.println(toolResult1);
198
+
199
+                // 第二步:模型分析表名,找到相关表
200
+                String step2Prompt = "请分析以下表名列表,找出与" + question + "相关的表。\n\n" +
201
+                        "**输出格式要求**:\n" +
202
+                        "- 如果需要数据库查询,请以固定格式输出:以\"相关表:\" 开头,中间用英文逗号隔开,以英文句号结尾\n" +
203
+                        "- 如果不需要进行数据库查询,请直接回答。\n\n" + toolResult1;
204
+
205
+                // 使用 CountDownLatch 等待生成完成
206
+                CountDownLatch latch = new CountDownLatch(1);
207
+                AtomicBoolean cancelled = new AtomicBoolean(false);
208
+                StringBuilder step2ContentBuilder = new StringBuilder();
209
+
210
+                // 生成第二步的响应
211
+                Prompt step3Prompt = Prompt.of(step2Prompt).attrPut("session",
212
+                        InMemoryChatSession.builder().messages(messages).build());
213
+                chatModel.prompt(step3Prompt)
214
+                        .stream()
215
+                        .subscribe(
216
+                                resp1 -> {
217
+                                    if (cancelled.get()) {
218
+                                        return;
219
+                                    }
220
+                                    // 收集流式响应内容
221
+                                    String chunk = resp1.getContent();
222
+                                    if (chunk != null && !chunk.isEmpty()) {
223
+                                        step2ContentBuilder.append(chunk);
224
+                                    }
225
+                                },
226
+                                error -> {
227
+                                    if (cancelled.getAndSet(true)) {
228
+                                        return;
229
+                                    }
230
+                                    try {
231
+                                        emitter.send(SseEmitter.event()
232
+                                                .name("error")
233
+                                                .data("生成回答时发生错误: " + error.getMessage()));
234
+                                    } catch (Exception sendEx) {
235
+                                    }
236
+                                    latch.countDown();
237
+                                },
238
+                                () -> {
239
+                                    if (cancelled.get()) {
240
+                                        return;
241
+                                    }
242
+                                    try {
243
+                                        String content2 = step2ContentBuilder.toString();
244
+                                        System.out.println(content2);
245
+                                        // 提取相关表名(从完整的模型响应中提取)
246
+                                        String relevantTables = extractRelevantTables(content2);
247
+                                        System.out.println(relevantTables);
248
+
249
+                                        // 检查是否需要继续执行(如果包含工具调用或明确需要数据库查询)
250
+                                        if (relevantTables.equals("")) {
251
+                                            // 不需要数据库查询,直接返回最终回答
252
+                                            // 估算token用量
253
+                                            StringBuilder fullPrompt = new StringBuilder();
254
+                                            for (ChatMessage msg : messages) {
255
+                                                fullPrompt.append(msg.getContent());
256
+                                            }
257
+                                            fullPrompt.append(step2Prompt);
258
+                                            tokenUsage[0].set((long) fullPrompt.length());
259
+                                            tokenUsage[1].set((long) content2.length());
260
+                                            tokenUsage[2].set(tokenUsage[0].get() + tokenUsage[1].get());
261
+
262
+                                            AnswerStreamResponse response = AnswerStreamResponse.content(content2,
263
+                                                    false);
264
+                                            response.setPromptTokens(tokenUsage[0].get());
265
+                                            response.setCompletionTokens(tokenUsage[1].get());
266
+                                            response.setTotalTokens(tokenUsage[2].get());
267
+                                            emitter.send(SseEmitter.event()
268
+                                                    .name("answer")
269
+                                                    .data(objectMapper.writeValueAsString(response)));
270
+
271
+                                            AnswerStreamResponse completeResponse = AnswerStreamResponse
272
+                                                    .content("[DONE]", true);
273
+                                            completeResponse.setPromptTokens(tokenUsage[0].get());
274
+                                            completeResponse.setCompletionTokens(tokenUsage[1].get());
275
+                                            completeResponse.setTotalTokens(tokenUsage[2].get());
276
+                                            emitter.send(SseEmitter.event()
277
+                                                    .name("completed")
278
+                                                    .data(objectMapper.writeValueAsString(completeResponse)));
279
+                                            latch.countDown();
280
+                                            return;
281
+                                        }
282
+
283
+                                        // 第三步:调用 getTableStructure 获取相关表的结构(使用本地方法替代MCP)
284
+                                        // 执行第三步:获取表结构
285
+                                        StringBuilder tableStructures = new StringBuilder();
286
+                                        for (String tableName : relevantTables.split(",")) {
287
+                                            tableName = tableName.trim();
288
+                                            if (!tableName.isEmpty()) {
289
+                                                String toolResult3 = getTableStructure(tableName);
290
+                                                tableStructures.append("表:").append(tableName).append("\n")
291
+                                                        .append(toolResult3)
292
+                                                        .append("\n\n");
293
+                                            }
294
+                                        }
295
+
296
+                                        // 第四步:模型生成 SQL 查询
297
+                                        String step4Prompt = "请根据以下表结构,生成" + question + "的SQL语句:\n\n"
298
+                                                + tableStructures.toString();
299
+
300
+                                        // 生成第四步的响应 - 同样需要收集完整响应后再提取SQL
301
+                                        StringBuilder step4ContentBuilder = new StringBuilder();
302
+
303
+                                        Prompt step5Prompt = Prompt.of(step4Prompt).attrPut("session",
304
+                                                InMemoryChatSession.builder().messages(messages).build());
305
+                                        chatModel.prompt(step5Prompt)
306
+                                                .stream()
307
+                                                .subscribe(
308
+                                                        resp2 -> {
309
+                                                            if (cancelled.get()) {
310
+                                                                return;
311
+                                                            }
312
+                                                            // 收集流式响应内容
313
+                                                            String chunk = resp2.getContent();
314
+                                                            if (chunk != null && !chunk.isEmpty()) {
315
+                                                                step4ContentBuilder.append(chunk);
316
+                                                            }
317
+                                                        },
318
+                                                        step4Error -> {
319
+                                                            if (cancelled.getAndSet(true)) {
320
+                                                                return;
321
+                                                            }
322
+                                                            try {
323
+                                                                emitter.send(SseEmitter.event()
324
+                                                                        .name("error")
325
+                                                                        .data("生成SQL时发生错误: "
326
+                                                                                + step4Error.getMessage()));
327
+                                                            } catch (Exception sendEx) {
328
+                                                            }
329
+                                                            latch.countDown();
330
+                                                        },
331
+                                                        () -> {
332
+                                                            if (cancelled.get()) {
333
+                                                                return;
334
+                                                            }
335
+                                                            try {
336
+                                                                String content4 = step4ContentBuilder.toString();
337
+
338
+                                                                // 提取 SQL 语句
339
+                                                                String sqlQuery = extractSqlFromContent(content4);
340
+
341
+                                                                // 第五步:调用 executeSQL 执行查询(使用本地方法替代MCP)
342
+                                                                // 执行第五步:执行 SQL 查询
343
+                                                                String toolResult5 = executeSQL(sqlQuery);
344
+
345
+                                                                // 第六步:模型生成最终回答
346
+                                                                String step6Prompt = "请根据以下查询结果,生成关于" + question
347
+                                                                        + "的最终回答:\n\n"
348
+                                                                        + toolResult5;
349
+
350
+                                                                // 估算prompt的token数量(包含历史对话)
351
+                                                                StringBuilder fullPrompt = new StringBuilder();
352
+                                                                for (ChatMessage msg : messages) {
353
+                                                                    fullPrompt.append(msg.getContent());
354
+                                                                }
355
+                                                                fullPrompt.append(step6Prompt);
356
+                                                                tokenUsage[0].set((long) fullPrompt.length());
357
+
358
+                                                                // 生成第六步的响应(流式)
359
+                                                                Prompt step7Prompt = Prompt.of(step6Prompt).attrPut(
360
+                                                                        "session",
361
+                                                                        InMemoryChatSession.builder().messages(messages)
362
+                                                                                .build());
363
+                                                                chatModel.prompt(step7Prompt)
364
+                                                                        .stream()
365
+                                                                        .map(resp -> {
366
+                                                                            String content = resp.getContent();
367
+                                                                            // 累计completion tokens
368
+                                                                            if (content != null) {
369
+                                                                                tokenUsage[1].addAndGet(
370
+                                                                                        (long) content.length());
371
+                                                                                tokenUsage[2].set(tokenUsage[0].get()
372
+                                                                                        + tokenUsage[1].get());
373
+                                                                            }
374
+                                                                            return content;
375
+                                                                        })
376
+                                                                        .subscribe(
377
+                                                                                content -> {
378
+                                                                                    if (cancelled.get()) {
379
+                                                                                        return;
380
+                                                                                    }
381
+                                                                                    try {
382
+                                                                                        if (content != null
383
+                                                                                                && !content.isEmpty()) {
384
+                                                                                            AnswerStreamResponse response = AnswerStreamResponse
385
+                                                                                                    .content(content,
386
+                                                                                                            false);
387
+                                                                                            response.setPromptTokens(
388
+                                                                                                    tokenUsage[0]
389
+                                                                                                            .get());
390
+                                                                                            response.setCompletionTokens(
391
+                                                                                                    tokenUsage[1]
392
+                                                                                                            .get());
393
+                                                                                            response.setTotalTokens(
394
+                                                                                                    tokenUsage[2]
395
+                                                                                                            .get());
396
+                                                                                            emitter.send(SseEmitter
397
+                                                                                                    .event()
398
+                                                                                                    .name("answer")
399
+                                                                                                    .data(objectMapper
400
+                                                                                                            .writeValueAsString(
401
+                                                                                                                    response)));
402
+                                                                                        }
403
+                                                                                    } catch (Exception e) {
404
+                                                                                        cancelled.set(true);
405
+                                                                                    }
406
+                                                                                },
407
+                                                                                error -> {
408
+                                                                                    if (cancelled.getAndSet(true)) {
409
+                                                                                        return;
410
+                                                                                    }
411
+                                                                                    try {
412
+                                                                                        AnswerStreamResponse errorResponse = AnswerStreamResponse
413
+                                                                                                .error("生成回答时发生错误: "
414
+                                                                                                        + error.getMessage());
415
+                                                                                        emitter.send(SseEmitter.event()
416
+                                                                                                .name("error")
417
+                                                                                                .data(objectMapper
418
+                                                                                                        .writeValueAsString(
419
+                                                                                                                errorResponse)));
420
+                                                                                    } catch (Exception sendEx) {
421
+                                                                                    }
422
+                                                                                    latch.countDown();
423
+                                                                                },
424
+                                                                                () -> {
425
+                                                                                    if (cancelled.getAndSet(true)) {
426
+                                                                                        return;
427
+                                                                                    }
428
+                                                                                    try {
429
+                                                                                        // 发送完成标记(包含最终token用量)
430
+                                                                                        AnswerStreamResponse completeResponse = AnswerStreamResponse
431
+                                                                                                .content("[DONE]",
432
+                                                                                                        true);
433
+                                                                                        completeResponse
434
+                                                                                                .setPromptTokens(
435
+                                                                                                        tokenUsage[0]
436
+                                                                                                                .get());
437
+                                                                                        completeResponse
438
+                                                                                                .setCompletionTokens(
439
+                                                                                                        tokenUsage[1]
440
+                                                                                                                .get());
441
+                                                                                        completeResponse.setTotalTokens(
442
+                                                                                                tokenUsage[2].get());
443
+                                                                                        emitter.send(SseEmitter.event()
444
+                                                                                                .name("completed")
445
+                                                                                                .data(objectMapper
446
+                                                                                                        .writeValueAsString(
447
+                                                                                                                completeResponse)));
448
+                                                                                    } catch (Exception e) {
449
+                                                                                    }
450
+                                                                                    latch.countDown();
451
+                                                                                });
452
+                                                            } catch (Exception e) {
453
+                                                                if (cancelled.getAndSet(true)) {
454
+                                                                    return;
455
+                                                                }
456
+                                                                try {
457
+                                                                    emitter.send(SseEmitter.event()
458
+                                                                            .name("error")
459
+                                                                            .data("抱歉,系统执行查询时遇到问题,请稍后重试。"));
460
+                                                                } catch (Exception sendEx) {
461
+                                                                }
462
+                                                                latch.countDown();
463
+                                                            }
464
+                                                        });
465
+                                    } catch (Exception e) {
466
+                                        if (cancelled.getAndSet(true)) {
467
+                                            return;
468
+                                        }
469
+                                        try {
470
+                                            emitter.send(SseEmitter.event()
471
+                                                    .name("error")
472
+                                                    .data("抱歉,系统执行查询时遇到问题,请稍后重试。"));
473
+                                        } catch (Exception sendEx) {
474
+                                        }
475
+                                        latch.countDown();
476
+                                    }
477
+                                });
478
+
479
+                // 等待生成完成
480
+                latch.await();
481
+
482
+                emitter.complete();
483
+
484
+            } catch (Exception e) {
485
+                try {
486
+                    emitter.send(SseEmitter.event()
487
+                            .name("error")
488
+                            .data("生成过程中发生错误: " + e.getMessage()));
489
+                } catch (IOException sendEx) {
490
+                    System.err.println("发送错误消息失败: " + sendEx.getMessage());
491
+                }
492
+                emitter.completeWithError(e);
493
+            } finally {
494
+                executor.shutdown();
495
+            }
496
+        });
497
+
498
+        return emitter;
499
+    }
500
+
501
+    /**
502
+     * 从模型响应中提取相关表名
503
+     */
504
+    private String extractRelevantTables(String content) {
505
+        if (content == null || content.trim().isEmpty()) {
506
+            return "";
507
+        }
508
+
509
+        // 尝试匹配 "相关表:" 或 "相关表\n" 格式
510
+        String[] markers = { "相关表:", "相关表", "相关表名:", "相关表名" };
511
+        int start = -1;
512
+        String matchedMarker = "";
513
+
514
+        for (String marker : markers) {
515
+            int idx = content.indexOf(marker);
516
+            if (idx != -1 && (start == -1 || idx < start)) {
517
+                start = idx;
518
+                matchedMarker = marker;
519
+            }
520
+        }
521
+
522
+        if (start != -1) {
523
+            start += matchedMarker.length();
524
+            // 跳过可能的换行符
525
+            while (start < content.length() && (content.charAt(start) == '\n' || content.charAt(start) == '\r'
526
+                    || content.charAt(start) == ' ' || content.charAt(start) == ':')) {
527
+                start++;
528
+            }
529
+
530
+            // 找到表名结束位置(换行、句号或字符串结尾)
531
+            int end = start;
532
+            while (end < content.length() && content.charAt(end) != '\n' && content.charAt(end) != '\r'
533
+                    && content.charAt(end) != '.') {
534
+                end++;
535
+            }
536
+
537
+            if (end > start) {
538
+                String tables = content.substring(start, end).trim();
539
+                // 限制相关表最多只保留10个
540
+                String[] tableNames = tables.split(",");
541
+                if (tableNames.length > 10) {
542
+                    StringBuilder limitedTables = new StringBuilder();
543
+                    for (int i = 0; i < 10; i++) {
544
+                        if (i > 0) {
545
+                            limitedTables.append(",");
546
+                        }
547
+                        limitedTables.append(tableNames[i].trim());
548
+                    }
549
+                    return limitedTables.toString();
550
+                }
551
+                return tables;
552
+            }
553
+        }
554
+        // 默认返回可能相关的表
555
+        return "";
556
+    }
557
+
558
+    /**
559
+     * 从模型响应中提取 SQL 语句
560
+     */
561
+    private String extractSqlFromContent(String content) {
562
+        // 简化处理,实际应该根据模型输出格式提取
563
+        if (content.contains("SELECT")) {
564
+            int start = content.indexOf("SELECT");
565
+            int end = content.indexOf(";", start);
566
+            if (end > start) {
567
+                return content.substring(start, end + 1);
568
+            }
569
+        }
570
+        return "";
571
+    }
572
+
573
+    @Override
574
+    public SseEmitter answerWithDocument(String topicId, String chatId, String question) throws Exception {
575
+        return langChainMilvusService.generateAnswerWithDocument(topicId, chatId, question);
576
+    }
577
+
578
+}

+ 3
- 3
oa-back/ruoyi-system/src/main/java/com/ruoyi/oa/domain/CmcTransfer.java Просмотреть файл

@@ -350,7 +350,7 @@ public class CmcTransfer extends BaseEntity
350 350
 
351 351
     public SysPost getAfterPost()
352 352
     {
353
-        return beforePost;
353
+        return afterPost;
354 354
     }
355 355
     public void setBeforeDept(SysDept beforeDept)
356 356
     {
@@ -370,7 +370,7 @@ public class CmcTransfer extends BaseEntity
370 370
 
371 371
     public SysDept getAfterDept()
372 372
     {
373
-        return beforeDept;
373
+        return afterDept;
374 374
     }
375 375
     public void setApplierUser(SysUser applierUser)
376 376
     {
@@ -400,7 +400,7 @@ public class CmcTransfer extends BaseEntity
400 400
 
401 401
     public SysUser getAfterDeptUser()
402 402
     {
403
-        return beforeDeptUser;
403
+        return afterDeptUser;
404 404
     }
405 405
     public void setZhUser(SysUser zhUser)
406 406
     {

+ 30
- 28
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcContractMapper.xml Просмотреть файл

@@ -206,14 +206,10 @@
206 206
     </select>
207 207
 
208 208
     <select id="selectCmcContractListByRange" parameterType="CmcContract" resultMap="CmcContractResult">
209
-        select distinct t1.contract_id, t1.contract_code, t1.contract_name, t1.contract_number, t1.tender_id, t1.t_project_name,t1.party_a_id, t1.party_a_name, t1.a_person,
210
-        t1.a_phone, t1.agent, t1.agent_person,t1.paid_amount, t1.paid_percentage, t1.invoice_amount, t1.invoice_percentage, p.project_number, p.project_name, p.project_source,
211
-        t1.agent_phone, t1.amount, t1.deposit, t1.contract_document, t1.drafter, t1.draft_nick_name, t1.draft_time, t1.remark, t1.sign_date, t1.sign_remark, t1.sign_scan,
212
-        t1.comment_type, t1.manager_comment, t1.manager_user_id, t1.manager_nick_name, t1.manager_time, t1.gm_user_id, t1.gm_nick_name, t1.gm_time, t1.gm_comment from
213
-        (select distinct c.contract_id, c.contract_code, c.contract_name, c.contract_number, c.tender_id, t.project_name as t_project_name,c.party_a_id, pa.party_a_name,
209
+        select distinct t3.*, p.project_number, p.project_name, p.project_source from
210
+        (select t1.*, t2.invoice_amount, t2.invoice_percentage from (select distinct c.contract_id, c.contract_code, c.contract_name, c.contract_number, c.tender_id, t.project_name as t_project_name,c.party_a_id, pa.party_a_name,
214 211
         t.a_person as a_person, t.a_phone as a_phone, t.agent as agent, t.agent_person as agent_person, t.agent_phone as agent_phone,
215 212
         sum(cp.paid_amount) as paid_amount, case c.amount when 0 then 0 else round(sum(cp.paid_amount) / c.amount * 100, 2) end as paid_percentage,
216
-        sum(ci.invoice_amount) as invoice_amount, case c.amount when 0 then 0 else round(sum(ci.invoice_amount) / c.amount * 100, 2) end as invoice_percentage,
217 213
         c.amount, c.deposit, c.contract_document, c.drafter, u.nick_name as draft_nick_name, c.draft_time, c.remark, c.sign_date, c.sign_remark, c.sign_scan,
218 214
         c.comment_type, c.manager_comment, c.manager_user_id, u1.nick_name as manager_nick_name, c.manager_time, c.gm_user_id, u2.nick_name as gm_nick_name, c.gm_time, c.gm_comment from cmc_contract as c
219 215
         left join sys_user as u on u.user_id = c.drafter
@@ -222,39 +218,45 @@
222 218
         left join cmc_tender as t on t.tender_id = c.tender_id
223 219
         left join cmc_party_a as pa on pa.party_a_id = c.party_a_id
224 220
         left join cmc_contract_paid as cp on cp.contract_id = c.contract_id
221
+        group by c.contract_id) as t1
222
+        left join
223
+        (select distinct c.contract_id, 
224
+        sum(ci.invoice_amount) as invoice_amount, case c.amount when 0 then 0 else round(sum(ci.invoice_amount) / c.amount * 100, 2) end as invoice_percentage
225
+        from cmc_contract as c
225 226
         left join cmc_contract_invoice as ci on ci.contract_id = c.contract_id
226
-        group by c.contract_id)
227
-        as t1
228
-        left join cmc_project_contract as pc on pc.contract_id = t1.contract_id
227
+        group by c.contract_id) as t2
228
+        on t1.contract_id = t2.contract_id)
229
+        as t3
230
+        left join cmc_project_contract as pc on pc.contract_id = t3.contract_id
229 231
         left join cmc_project as p on pc.project_id = p.project_id
230 232
         <where>
231
-            <if test="contractId != null  and contractId != ''"> and t1.contract_id like concat('%', #{contractId}, '%')</if>
232
-            <if test="contractName!= null  and contractName != ''"> and t1.contract_name like concat('%', #{contractName}, '%')</if>
233
-            <if test="contractCode!= null  and contractCode != ''"> and t1.contract_code like concat('%', #{contractCode}, '%')</if>
234
-            <if test="tenderId != null  and tenderId != ''"> and t1.tender_id = #{tenderId}</if>
235
-            <if test="contractNumber != null  and contractNumber != ''"> and t1.contract_number = #{contractNumber}</if>
236
-            <if test="partyAId != null  and partyAId != ''"> and t1.party_a_id = #{partyAId}</if>
237
-            <if test="amount != null "> and t1.amount = #{amount}</if>
238
-            <if test="deposit != null "> and t1.deposit = #{deposit}</if>
239
-            <if test="contractDocument != null  and contractDocument != ''"> and t1.contract_document = #{contractDocument}</if>
240
-            <if test="drafter != null "> and t1.drafter = #{drafter}</if>
241
-            <if test="draftTime != null "> and t1.draft_time = #{draftTime}</if>
242
-            <if test="signDate != null"> and YEAR(t1.sign_date) = YEAR(#{signDate})</if>
243
-            <if test="signRemark != null  and signRemark != ''"> and t1.sign_remark = #{signRemark}</if>
244
-            <if test="signScan != null  and signScan != ''"> and t1.sign_scan = #{signScan}</if>
245
-            <if test="commentType != null  and commentType != ''"> and t1.comment_type = #{commentType}</if>
233
+            <if test="contractId != null  and contractId != ''"> and t3.contract_id like concat('%', #{contractId}, '%')</if>
234
+            <if test="contractName!= null  and contractName != ''"> and t3.contract_name like concat('%', #{contractName}, '%')</if>
235
+            <if test="contractCode!= null  and contractCode != ''"> and t3.contract_code like concat('%', #{contractCode}, '%')</if>
236
+            <if test="tenderId != null  and tenderId != ''"> and t3.tender_id = #{tenderId}</if>
237
+            <if test="contractNumber != null  and contractNumber != ''"> and t3.contract_number = #{contractNumber}</if>
238
+            <if test="partyAId != null  and partyAId != ''"> and t3.party_a_id = #{partyAId}</if>
239
+            <if test="amount != null "> and t3.amount = #{amount}</if>
240
+            <if test="deposit != null "> and t3.deposit = #{deposit}</if>
241
+            <if test="contractDocument != null  and contractDocument != ''"> and t3.contract_document = #{contractDocument}</if>
242
+            <if test="drafter != null "> and t3.drafter = #{drafter}</if>
243
+            <if test="draftTime != null "> and t3.draft_time = #{draftTime}</if>
244
+            <if test="signDate != null"> and YEAR(t3.sign_date) = YEAR(#{signDate})</if>
245
+            <if test="signRemark != null  and signRemark != ''"> and t3.sign_remark = #{signRemark}</if>
246
+            <if test="signScan != null  and signScan != ''"> and t3.sign_scan = #{signScan}</if>
247
+            <if test="commentType != null  and commentType != ''"> and t3.comment_type = #{commentType}</if>
246 248
             <if test="projectSource != null  and projectSource != ''"> and p.project_source = #{projectSource}</if>
247 249
             <if test="projectName != null  and projectName != ''"> and p.project_name like concat('%', #{projectName}, '%')</if>
248 250
             <if test="partyAName != null  and partyAName != ''"> and pa.party_a_name like concat('%', #{partyAName}, '%')</if>
249 251
             <if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
250
-                and date_format(t1.sign_date,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
252
+                and date_format(t3.sign_date,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
251 253
             </if>
252 254
             <if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
253
-                and date_format(t1.sign_date,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
255
+                and date_format(t3.sign_date,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
254 256
             </if>
255 257
         </where>
256
-        group by t1.contract_id
257
-        order by t1.draft_time desc
258
+        group by t3.contract_id
259
+        order by t3.draft_time desc
258 260
     </select>
259 261
 
260 262
     <select id="selectCmcContractStatistic" resultMap="CmcContractResult" >

+ 27
- 24
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcSubContractMapper.xml Просмотреть файл

@@ -141,12 +141,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
141 141
     </select>
142 142
 
143 143
     <select id="selectCmcSubContractListByRange" parameterType="CmcSubContract" resultMap="CmcSubContractResult">
144
-        select distinct t1.sub_contract_id, t1.sub_contract_name, t1.sub_amount, t1.partner_id, t1.partner_name, t1.contact_person, t1.telephone, t1.contract_document, t1.drafter,
145
-        t1.paid_amount, t1.paid_percentage, t1.invoice_amount, t1.invoice_percentage, t1.draft_nick_name, t1.draft_time, t1.remark, t1.sign_date, t1.sign_remark, t1.sign_scan,
146
-        t1.comment_type, t1.manager_comment, t1.manager_user_id, t1.manager_nick_name, t1.manager_time, t1.gm_user_id, t1.gm_nick_name, t1.gm_time, t1.gm_comment, p.project_number, p.project_name, p.project_source from
147
-        (select distinct sc.sub_contract_id, sc.sub_contract_name, sc.sub_amount, sc.partner_id, pa.partner_name, sc.contact_person, sc.telephone, sc.contract_document, sc.drafter,
144
+        select distinct t3.*, p.project_number, p.project_name, p.project_source from
145
+        (select t1.*, t2.invoice_amount, t2.invoice_percentage from (select distinct sc.sub_contract_id, sc.sub_contract_name, sc.sub_amount, sc.partner_id, pa.partner_name, sc.contact_person, sc.telephone, sc.contract_document, sc.drafter,
148 146
         sum(cp.paid_amount) as paid_amount, case sc.sub_amount when 0 then 0 else round(sum(cp.paid_amount) / sc.sub_amount * 100, 2) end as paid_percentage,
149
-        sum(ci.invoice_amount) as invoice_amount, case sc.sub_amount when 0 then 0 else round(sum(ci.invoice_amount) / sc.sub_amount * 100, 2) end as invoice_percentage,
150 147
         u.nick_name as draft_nick_name, sc.draft_time, sc.remark, sc.sign_date, sc.sign_remark, sc.sign_scan, sc.comment_type, sc.manager_comment,
151 148
         sc.manager_user_id, u1.nick_name as manager_nick_name, sc.manager_time, sc.gm_user_id, u2.nick_name as gm_nick_name, sc.gm_time, sc.gm_comment from cmc_sub_contract as sc
152 149
         left join sys_user as u on u.user_id = sc.drafter
@@ -154,34 +151,40 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
154 151
         left join sys_user as u2 on u2.user_id = sc.gm_user_id
155 152
         left join cmc_partner as pa on pa.partner_id = sc.partner_id
156 153
         left join cmc_contract_paid as cp on cp.contract_id = sc.sub_contract_id
154
+        group by sc.sub_contract_id) as t1
155
+        left join
156
+        (select distinct sc.sub_contract_id, 
157
+        sum(ci.invoice_amount) as invoice_amount, case sc.sub_amount when 0 then 0 else round(sum(ci.invoice_amount) / sc.sub_amount * 100, 2) end as invoice_percentage
158
+        from cmc_sub_contract as sc
157 159
         left join cmc_contract_invoice as ci on ci.contract_id = sc.sub_contract_id
158
-        group by sc.sub_contract_id)
159
-        as t1
160
-        left join cmc_project_sub_contract as psc on psc.sub_contract_id = t1.sub_contract_id
160
+        group by sc.sub_contract_id) as t2
161
+        on t1.sub_contract_id = t2.sub_contract_id)
162
+        as t3
163
+        left join cmc_project_sub_contract as psc on psc.sub_contract_id = t3.sub_contract_id
161 164
         left join cmc_project as p on psc.project_id = p.project_id
162 165
         <where>
163
-            <if test="subContractName != null  and subContractName != ''"> and t1.sub_contract_name like concat('%', #{subContractName}, '%')</if>
164
-            <if test="subAmount != null "> and t1.sub_amount = #{subAmount}</if>
165
-            <if test="partnerId != null  and partnerId != ''"> and t1.partner_id = #{partnerId}</if>
166
-            <if test="contactPerson != null  and contactPerson != ''"> and t1.contact_person = #{contactPerson}</if>
167
-            <if test="telephone != null  and telephone != ''"> and t1.telephone = #{telephone}</if>
168
-            <if test="contractDocument != null  and contractDocument != ''"> and t1.contract_document = #{contractDocument}</if>
169
-            <if test="drafter != null "> and t1.drafter = #{drafter}</if>
170
-            <if test="draftTime != null "> and t1.draft_time = #{draftTime}</if>
171
-            <if test="signDate != null "> and YEAR(t1.sign_date) = YEAR(#{signDate})</if>
172
-            <if test="signRemark != null  and signRemark != ''"> and t1.sign_remark = #{signRemark}</if>
173
-            <if test="signScan != null  and signScan != ''"> and t1.sign_scan = #{signScan}</if>
174
-            <if test="commentType != null  and commentType != ''"> and t1.comment_type = #{commentType}</if>
166
+            <if test="subContractName != null  and subContractName != ''"> and t3.sub_contract_name like concat('%', #{subContractName}, '%')</if>
167
+            <if test="subAmount != null "> and t3.sub_amount = #{subAmount}</if>
168
+            <if test="partnerId != null  and partnerId != ''"> and t3.partner_id = #{partnerId}</if>
169
+            <if test="contactPerson != null  and contactPerson != ''"> and t3.contact_person = #{contactPerson}</if>
170
+            <if test="telephone != null  and telephone != ''"> and t3.telephone = #{telephone}</if>
171
+            <if test="contractDocument != null  and contractDocument != ''"> and t3.contract_document = #{contractDocument}</if>
172
+            <if test="drafter != null "> and t3.drafter = #{drafter}</if>
173
+            <if test="draftTime != null "> and t3.draft_time = #{draftTime}</if>
174
+            <if test="signDate != null "> and YEAR(t3.sign_date) = YEAR(#{signDate})</if>
175
+            <if test="signRemark != null  and signRemark != ''"> and t3.sign_remark = #{signRemark}</if>
176
+            <if test="signScan != null  and signScan != ''"> and t3.sign_scan = #{signScan}</if>
177
+            <if test="commentType != null  and commentType != ''"> and t3.comment_type = #{commentType}</if>
175 178
             <if test="projectSource != null  and projectSource != ''"> and p.project_source = #{projectSource}</if>
176 179
             <if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
177
-                and date_format(t1.sign_date,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
180
+                and date_format(t3.sign_date,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
178 181
             </if>
179 182
             <if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
180
-                and date_format(t1.sign_date,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
183
+                and date_format(t3.sign_date,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
181 184
             </if>
182 185
         </where>
183
-        group by t1.sub_contract_id
184
-        order by t1.draft_time desc
186
+        group by t3.sub_contract_id
187
+        order by t3.draft_time desc
185 188
     </select>
186 189
 
187 190
     <select id="selectCmcSubContractBySubContractId" parameterType="String" resultMap="CmcSubContractResult">

+ 5
- 3
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcWageMapper.xml Просмотреть файл

@@ -50,11 +50,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
50 50
                w.deduct_total, w.social_security_unit, w.individual_income_tax, w.paid_wage, w.pay_day, w.pay_month, w.remark, w.performance_id, u.nick_name from cmc_wage as w
51 51
         left join sys_user as u on u.user_id = w.user_id
52 52
         left join sys_dept as d on d.dept_id = u.dept_id
53
+        left join cmc_post_salary ps on u.post_level = ps.post_level and u.salary_level = ps.salary_level
53 54
     </sql>
54 55
 
55 56
     <select id="selectCmcWageList" parameterType="CmcWage" resultMap="CmcWageResult">
56 57
         <include refid="selectCmcWageVo"/>
57
-        <where>  
58
+        <where>
59
+            and u.del_flag = '0'
58 60
             <if test="userId != null "> and w.user_id = #{userId}</if>
59 61
             <if test="deptId != null "> and u.dept_id = #{deptId}</if>
60 62
             <if test="baseSalary != null "> and w.base_salary = #{baseSalary}</if>
@@ -79,8 +81,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
79 81
             <if test="payDay != null "> and w.pay_day = #{payDay}</if>
80 82
             <if test="payMonth != null "> and MONTH(w.pay_month) = MONTH(#{payMonth})</if>
81 83
             <if test="performanceId != null and performanceId != ''"> and w.performance_id = #{performanceId}</if>
82
-        </where>
83
-        order by w.user_id, w.pay_month
84
+        </where>        
85
+		order by u.dept_id, ps.post_level desc, ps.salary_level desc, u.user_id, w.pay_month
84 86
     </select>
85 87
     
86 88
     <select id="selectCmcWageByWageId" parameterType="Integer" resultMap="CmcWageResult">

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

@@ -25,44 +25,74 @@ export function getContextFile(question, collectionName) {
25 25
   })
26 26
 }
27 27
 
28
+/**
29
+ * 解析SSE事件 - 优化版
30
+ * 正确处理多行data和事件类型
31
+ */
28 32
 function parseSseEvents(buffer) {
29
-  // SSE 事件以空行分隔:\n\n(兼容 \r\n)
30 33
   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())
34
+  const lines = buffer.split(/\r?\n/)
35
+  let currentEvent = null
36
+  let currentData = []
37
+  let remaining = []
38
+  let i = 0
39
+  
40
+  for (; i < lines.length; i++) {
41
+    const line = lines[i]
42
+    
43
+    // 空行表示事件结束
44
+    if (line === '') {
45
+      if (currentData.length > 0) {
46
+        const data = currentData.join('\n')
47
+        events.push({
48
+          event: currentEvent || 'message',
49
+          data: data
50
+        })
51
+        currentEvent = null
52
+        currentData = []
43 53
       }
54
+      continue
55
+    }
56
+    
57
+    // 解析事件类型
58
+    if (line.startsWith('event:')) {
59
+      currentEvent = line.slice(6).trim()
60
+      continue
61
+    }
62
+    
63
+    // 解析数据行
64
+    if (line.startsWith('data:')) {
65
+      const dataValue = line.slice(5).trimStart()
66
+      currentData.push(dataValue)
67
+      continue
44 68
     }
45
-    const data = dataLines.join('\n')
46
-    if (data !== '') events.push(data)
47 69
   }
48
-
49
-  return { events, rest }
70
+  
71
+  // 保存剩余未完成的数据
72
+  if (i < lines.length) {
73
+    remaining = lines.slice(i).join('\n')
74
+  } else if (currentData.length > 0) {
75
+    // 如果最后没有空行,但数据已完整,也要保留
76
+    remaining = lines.slice(i - currentData.length - (currentEvent ? 1 : 0)).join('\n')
77
+  } else {
78
+    remaining = ''
79
+  }
80
+  
81
+  return { events, rest: remaining }
50 82
 }
51 83
 
84
+/**
85
+ * 规范化SSE数据
86
+ */
52 87
 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
-    }
88
+  if (data == null) return ''
89
+  let s = String(data)
90
+  // 移除可能的 "data:" 前缀
91
+  while (s.startsWith('data:')) {
92
+    s = s.slice(5)
93
+    if (s.startsWith(' ')) s = s.slice(1)
64 94
   }
65
-  return trimmed
95
+  return s
66 96
 }
67 97
 
68 98
 function stripToolCallStream(text, state) {
@@ -87,9 +117,17 @@ function stripToolCallStream(text, state) {
87 117
   }
88 118
 }
89 119
 
120
+/**
121
+ * 流式请求SSE - 优化版
122
+ * @param {string} url - 请求URL
123
+ * @param {Function} onMessage - 收到消息时的回调,接收解析后的JSON对象和事件类型
124
+ * @param {Function} onError - 错误回调
125
+ * @param {Function} onComplete - 完成回调
126
+ * @returns {AbortController} 用于取消请求的控制器
127
+ */
90 128
 function streamFetchSse(url, onMessage, onError, onComplete) {
91 129
   const controller = new AbortController()
92
-  const toolCallState = { inToolCall: false }
130
+
93 131
   fetch(url, {
94 132
     method: 'GET',
95 133
     headers: {
@@ -112,44 +150,57 @@ function streamFetchSse(url, onMessage, onError, onComplete) {
112 150
         if (done) break
113 151
 
114 152
         buffer += decoder.decode(value, { stream: true })
115
-        const parsed = parseSseEvents(buffer)
116
-        buffer = parsed.rest
153
+        
154
+        // 解析SSE事件
155
+        const { events, rest } = parseSseEvents(buffer)
156
+        buffer = rest
117 157
 
118
-        for (const data of parsed.events) {
119
-          const normalized = normalizeSseData(data)
120
-          if (normalized === '[DONE]') {
121
-            onComplete()
158
+        for (const event of events) {
159
+          const normalizedData = normalizeSseData(event.data)
160
+          
161
+          // 检查是否结束
162
+          if (normalizedData === '[DONE]') {
163
+            onComplete && onComplete()
122 164
             controller.abort()
123 165
             return
124 166
           }
125
-          const visible = stripToolCallStream(normalized, toolCallState)
126
-          if (visible !== '') onMessage(visible)
127
-        }
128
-      }
129
-
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)
167
+          
168
+          try {
169
+            const jsonData = JSON.parse(normalizedData)
170
+            // 根据事件名称分发,传递JSON对象和事件类型
171
+            onMessage && onMessage(jsonData, event.event)
172
+          } catch (e) {
173
+            console.warn('解析JSON失败,作为普通文本处理:', normalizedData)
174
+            onMessage && onMessage({ content: normalizedData }, event.event)
138 175
           }
139 176
         }
140 177
       }
141
-      onComplete()
178
+      
179
+      onComplete && onComplete()
142 180
     })
143 181
     .catch((error) => {
144
-      if (error.name === 'AbortError') return
182
+      if (error.name === 'AbortError') {
183
+        console.log('请求已取消')
184
+        return
185
+      }
145 186
       console.error('流式请求错误:', error)
146
-      onError(error)
187
+      onError && onError(error)
147 188
     })
148 189
 
149 190
   return controller
150 191
 }
151 192
 
152
-// 流式回答API(SSE)
193
+/**
194
+ * 流式回答API(SSE)
195
+ * @param {string} question - 问题
196
+ * @param {string} collectionName - 知识库名称
197
+ * @param {Function} onMessage - 收到消息时的回调,接收参数:(jsonData, eventType)
198
+ *                              - jsonData: 解析后的JSON对象,包含content、tokenUsage等字段
199
+ *                              - eventType: 事件类型(如'answer'、'error'、'completed')
200
+ * @param {Function} onError - 错误回调
201
+ * @param {Function} onComplete - 完成回调
202
+ * @returns {AbortController} 用于取消请求的控制器
203
+ */
153 204
 export function getAnswerStream(question, collectionName, onMessage, onError, onComplete) {
154 205
   const baseURL = process.env.VUE_APP_BASE_API
155 206
   const url = `${baseURL}/llm/rag/answer?question=${encodeURIComponent(question)}&collectionName=${encodeURIComponent(collectionName)}`

+ 113
- 45
oa-ui/src/api/llm/session.js Просмотреть файл

@@ -25,35 +25,69 @@ export function getAnswerWithDocument(question) {
25 25
   })
26 26
 }
27 27
 
28
+/**
29
+ * 解析SSE事件 - 优化版
30
+ * 正确处理多行data和事件类型
31
+ */
28 32
 function parseSseEvents(buffer) {
29
-  // SSE 事件以空行分隔:\n\n(兼容 \r\n)
30 33
   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())
34
+  const lines = buffer.split(/\r?\n/)
35
+  let currentEvent = null
36
+  let currentData = []
37
+  let remaining = []
38
+  let i = 0
39
+  
40
+  for (; i < lines.length; i++) {
41
+    const line = lines[i]
42
+    
43
+    // 空行表示事件结束
44
+    if (line === '') {
45
+      if (currentData.length > 0) {
46
+        const data = currentData.join('\n')
47
+        events.push({
48
+          event: currentEvent || 'message',
49
+          data: data
50
+        })
51
+        currentEvent = null
52
+        currentData = []
43 53
       }
54
+      continue
55
+    }
56
+    
57
+    // 解析事件类型
58
+    if (line.startsWith('event:')) {
59
+      currentEvent = line.slice(6).trim()
60
+      continue
61
+    }
62
+    
63
+    // 解析数据行
64
+    if (line.startsWith('data:')) {
65
+      const dataValue = line.slice(5).trimStart()
66
+      currentData.push(dataValue)
67
+      continue
44 68
     }
45
-    const data = dataLines.join('\n')
46
-    if (data !== '') events.push(data)
47 69
   }
48
-
49
-  return { events, rest }
70
+  
71
+  // 保存剩余未完成的数据
72
+  if (i < lines.length) {
73
+    remaining = lines.slice(i).join('\n')
74
+  } else if (currentData.length > 0) {
75
+    // 如果最后没有空行,但数据已完整,也要保留
76
+    remaining = lines.slice(i - currentData.length - (currentEvent ? 1 : 0)).join('\n')
77
+  } else {
78
+    remaining = ''
79
+  }
80
+  
81
+  return { events, rest: remaining }
50 82
 }
51 83
 
84
+/**
85
+ * 规范化SSE数据
86
+ */
52 87
 function normalizeSseData(data) {
53 88
   if (data == null) return ''
54 89
   let s = String(data)
55
-  // 有些后端会把 payload 自己也带上 "data:",这里做兼容剥离
56
-  // 例如:data:data: xxx  ->  xxx
90
+  // 移除可能的 "data:" 前缀
57 91
   while (s.startsWith('data:')) {
58 92
     s = s.slice(5)
59 93
     if (s.startsWith(' ')) s = s.slice(1)
@@ -89,9 +123,17 @@ function stripToolCallStream(text, state) {
89 123
   return out
90 124
 }
91 125
 
126
+/**
127
+ * 流式请求SSE - 优化版
128
+ * @param {string} url - 请求URL
129
+ * @param {Function} onMessage - 收到消息时的回调,接收解析后的JSON对象和事件类型
130
+ * @param {Function} onError - 错误回调
131
+ * @param {Function} onComplete - 完成回调
132
+ * @returns {AbortController} 用于取消请求的控制器
133
+ */
92 134
 function streamFetchSse(url, onMessage, onError, onComplete) {
93 135
   const controller = new AbortController()
94
-  const toolCallState = { inToolCall: false }
136
+
95 137
   fetch(url, {
96 138
     method: 'GET',
97 139
     headers: {
@@ -114,51 +156,77 @@ function streamFetchSse(url, onMessage, onError, onComplete) {
114 156
         if (done) break
115 157
 
116 158
         buffer += decoder.decode(value, { stream: true })
117
-        const parsed = parseSseEvents(buffer)
118
-        buffer = parsed.rest
119
-
120
-        for (const data of parsed.events) {
121
-          const normalized = normalizeSseData(data)
122
-          if (normalized === '[DONE]') {
123
-            onComplete()
159
+        
160
+        // 解析SSE事件
161
+        const { events, rest } = parseSseEvents(buffer)
162
+        buffer = rest
163
+
164
+        for (const event of events) {
165
+          const normalizedData = normalizeSseData(event.data)
166
+          
167
+          // 检查是否结束
168
+          if (normalizedData === '[DONE]') {
169
+            onComplete && onComplete()
124 170
             controller.abort()
125 171
             return
126 172
           }
127
-          const visible = stripToolCallStream(normalized, toolCallState)
128
-          if (visible !== '') onMessage(visible)
129
-        }
130
-      }
131
-
132
-      // 兜底:流结束但没收到 [DONE]
133
-      if (buffer.trim()) {
134
-        const parsed = parseSseEvents(buffer + '\n\n')
135
-        for (const data of parsed.events) {
136
-          const normalized = normalizeSseData(data)
137
-          if (normalized !== '' && normalized !== '[DONE]') {
138
-            const visible = stripToolCallStream(normalized, toolCallState)
139
-            if (visible !== '') onMessage(visible)
173
+          
174
+          try {
175
+            const jsonData = JSON.parse(normalizedData)
176
+            // 根据事件名称分发,传递JSON对象和事件类型
177
+            onMessage && onMessage(jsonData, event.event)
178
+          } catch (e) {
179
+            console.warn('解析JSON失败,作为普通文本处理:', normalizedData)
180
+            onMessage && onMessage({ content: normalizedData }, event.event)
140 181
           }
141 182
         }
142 183
       }
143
-      onComplete()
184
+      
185
+      onComplete && onComplete()
144 186
     })
145 187
     .catch((error) => {
146
-      if (error.name === 'AbortError') return
188
+      if (error.name === 'AbortError') {
189
+        console.log('请求已取消')
190
+        return
191
+      }
147 192
       console.error('流式请求错误:', error)
148
-      onError(error)
193
+      onError && onError(error)
149 194
     })
150 195
 
151 196
   return controller
152 197
 }
153 198
 
154
-// 流式回答API(SSE)
199
+/**
200
+ * 流式回答API(SSE)
201
+ * @param {Object} params - 参数对象
202
+ * @param {string} params.topicId - 话题ID
203
+ * @param {string} params.question - 问题
204
+ * @param {Function} onMessage - 收到消息时的回调,接收参数:(jsonData, eventType)
205
+ *                              - jsonData: 解析后的JSON对象,包含content、tokenUsage等字段
206
+ *                              - eventType: 事件类型(如'answer'、'error'、'completed')
207
+ * @param {Function} onError - 错误回调
208
+ * @param {Function} onComplete - 完成回调
209
+ * @returns {AbortController} 用于取消请求的控制器
210
+ */
155 211
 export function getAnswerStream(params, onMessage, onError, onComplete) {
156 212
   const baseURL = process.env.VUE_APP_BASE_API
157 213
   const url = `${baseURL}/llm/session/answer?topicId=${params.topicId}&question=${encodeURIComponent(params.question)}`
158 214
   return streamFetchSse(url, onMessage, onError, onComplete)
159 215
 }
160 216
 
161
-// 流式回答API(带文档)- 简化版
217
+/**
218
+ * 流式回答API(带文档)
219
+ * @param {Object} params - 参数对象
220
+ * @param {string} params.topicId - 话题ID
221
+ * @param {string} params.chatId - 聊天ID
222
+ * @param {string} params.question - 问题
223
+ * @param {Function} onMessage - 收到消息时的回调,接收参数:(jsonData, eventType)
224
+ *                              - jsonData: 解析后的JSON对象,包含content、tokenUsage等字段
225
+ *                              - eventType: 事件类型(如'answer'、'error'、'completed')
226
+ * @param {Function} onError - 错误回调
227
+ * @param {Function} onComplete - 完成回调
228
+ * @returns {AbortController} 用于取消请求的控制器
229
+ */
162 230
 export function getAnswerWithDocumentStream(params, onMessage, onError, onComplete) {
163 231
   const baseURL = process.env.VUE_APP_BASE_API
164 232
   const url = `${baseURL}/llm/session/answerWithDocument?topicId=${params.topicId}&chatId=${params.chatId}&question=${encodeURIComponent(params.question)}`

+ 273
- 95
oa-ui/src/views/llm/agent/AgentDetail.vue Просмотреть файл

@@ -301,8 +301,12 @@
301 301
                           :before-upload="beforeUpload" :show-file-list="true" :limit="1"
302 302
                           :disabled="bidFileList.length > 0">
303 303
                         </el-upload>
304
-                        <el-button type="primary" @click="generateBid" :disabled="!currentFilename">生成标书</el-button>
305
-                        <el-button type="primary" icon="el-icon-download" @click="exportWord" :disabled="!bidResult">
304
+                        <el-button type="primary" @click="isStreaming ? stopGeneration() : generateBid()"
305
+                          :disabled="!currentFilename && !isStreaming">
306
+                          {{ isStreaming ? '停止生成' : '生成标书' }}
307
+                        </el-button>
308
+                        <el-button type="primary" icon="el-icon-download" @click="exportWord"
309
+                          :disabled="isStreaming">
306 310
                           导出Word文档
307 311
                         </el-button>
308 312
                       </div>
@@ -311,14 +315,12 @@
311 315
                   <div v-if="directoryTree.length > 0" class="chapter-section">
312 316
                     <div class="chapter-content-wrapper">
313 317
                       <div class="directory-tree">
314
-                        <div class="directory-actions">
315
-                          <el-button type="success" size="small" @click="saveDirectory" icon="el-icon-check"
316
-                            title="保存目录">保存目录</el-button>
317
-                        </div>
318 318
                         <div class="tree-container">
319
-                          <div v-for="(item, index) in directoryTree" :key="index" class="directory-item level-1">
319
+                          <div v-for="(item, index) in directoryTree" :key="index" class="directory-item level-1"
320
+                            :class="{ 'selected': selectedNode === item }">
320 321
                             <div class="directory-node">
321
-                              <el-input v-model="item.title" placeholder="请输入目录名称" size="small" />
322
+                              <el-input v-model="item.title" placeholder="请输入目录名称" size="small"
323
+                                @click.native.stop="selectNode(item)" />
322 324
                               <div class="node-actions">
323 325
                                 <el-button type="primary" size="mini" icon="el-icon-plus" circle
324 326
                                   @click.stop="addChildNode(item)" title="添加子节点" />
@@ -328,9 +330,10 @@
328 330
                             </div>
329 331
                             <div v-if="item.children && item.children.length > 0">
330 332
                               <div v-for="(child, childIndex) in item.children" :key="childIndex"
331
-                                class="directory-item level-2">
333
+                                class="directory-item level-2" :class="{ 'selected': selectedNode === child }">
332 334
                                 <div class="directory-node">
333
-                                  <el-input v-model="child.title" placeholder="请输入子目录名称" size="small" />
335
+                                  <el-input v-model="child.title" placeholder="请输入子目录名称" size="small"
336
+                                    @click.native.stop="selectNode(child)" />
334 337
                                   <div class="node-actions">
335 338
                                     <el-button type="primary" size="mini" icon="el-icon-plus" circle
336 339
                                       @click.stop="addChildNode(child)" title="添加子节点" />
@@ -359,21 +362,51 @@
359 362
                         </div>
360 363
                       </div>
361 364
                       <div class="bid-content-area">
362
-                        <!-- 在 bid-content-area 内部,bid-result-content 之前添加 -->
365
+                        <!-- 生成进度提示(仅在生成章节内容时显示) -->
363 366
                         <div v-if="isStreaming" class="generation-progress"
364 367
                           style="padding: 8px 12px; background: #ecf5ff; border-radius: 4px; margin-bottom: 12px;">
365 368
                           <i class="el-icon-loading" style="margin-right: 8px;"></i>
366 369
                           <span>正在生成中... {{ currentChapterIndex }}/{{ totalChapters }} 章</span>
367 370
                         </div>
368
-                        <div v-if="bidResult" class="bid-result-content">
371
+                        <!-- 章节内容(生成中显示,生成完成后不再显示) -->
372
+                        <div v-if="isStreaming && bidResult" class="bid-result-content">
373
+                          <div v-html="bidResult"></div>
374
+                        </div>
375
+                        <!-- 已选中章节的预览内容(非生成状态且不是完成状态) -->
376
+                        <div v-if="!isStreaming && !generationCompleted && bidResult" class="bid-result-content">
369 377
                           <div v-html="bidResult"></div>
370 378
                         </div>
371
-                        <div v-else class="empty-bid-content">
379
+                        <!-- 生成完成提示(居中显示,不与章节内容同时出现) -->
380
+                        <div v-if="generationCompleted" class="empty-bid-content generation-completed"
381
+                          style="color: #67c23a;">
382
+                          <i class="el-icon-success" style="font-size: 64px; margin-bottom: 20px; opacity: 0.8;"></i>
383
+                          <p style="font-size: 18px; font-weight: 500;">生成标书完成</p>
384
+                        </div>
385
+                        <!-- 初始空状态(未选中章节且未开始生成) -->
386
+                        <div v-if="!isStreaming && !generationCompleted && !bidResult" class="empty-bid-content">
372 387
                           <i class="el-icon-file-text"></i>
373 388
                           <p>{{ selectedNode ? '已选中章节: ' + selectedNode.title : '请选择章节或直接生成全部内容' }}</p>
374 389
                         </div>
375 390
                       </div>
376 391
                     </div>
392
+                    <div class="directory-toolbar">
393
+                      <el-button type="success" size="small" @click="saveDirectory" icon="el-icon-check"
394
+                        title="保存目录">保存目录</el-button>
395
+                      <div class="token-info-container">
396
+                        <div class="token-info-row">
397
+                          <span class="token-label">提示词:</span>
398
+                          <span class="token-value">{{ tokenUsage.promptTokens }}</span>
399
+                        </div>
400
+                        <div class="token-info-row">
401
+                          <span class="token-label">生成:</span>
402
+                          <span class="token-value">{{ tokenUsage.completionTokens }}</span>
403
+                        </div>
404
+                        <div class="token-info-row">
405
+                          <span class="token-label">总计:</span>
406
+                          <span class="token-value">{{ tokenUsage.totalTokens }}</span>
407
+                        </div>
408
+                      </div>
409
+                    </div>
377 410
                   </div>
378 411
                 </div>
379 412
               </div>
@@ -426,7 +459,7 @@ export default {
426 459
       loading: false,
427 460
       agentInfo: null,
428 461
       openingMessage: '',
429
-      scoringTabLabel: '评标准',
462
+      scoringTabLabel: '评标准',
430 463
       chatMessages: [],
431 464
       inputMessage: '',
432 465
       isTyping: false,
@@ -478,8 +511,18 @@ export default {
478 511
       currentStreamingChapter: null, // 当前正在流式生成的章节标题
479 512
       streamingContent: '', // 当前流式生成的内容
480 513
       isStreaming: false, // 是否正在流式生成
514
+      generationCompleted: false, // 标书生成是否已完成
515
+      abortController: null, // 用于取消流式请求
481 516
       currentChapterIndex: 0, // 当前章节索引
482
-      totalChapters: 0 // 总章节数
517
+      totalChapters: 0, // 总章节数
518
+
519
+      // token用量相关
520
+      tokenUsage: {
521
+        promptTokens: 0,
522
+        completionTokens: 0,
523
+        totalTokens: 0
524
+      },
525
+      chapterTokenUsage: {} // 存储每个章节的token用量
483 526
     }
484 527
   },
485 528
   computed: {
@@ -518,12 +561,12 @@ export default {
518 561
         const response = await getAgent(agentId)
519 562
         this.agentInfo = response.data
520 563
 
521
-        // 设置评标准/应价人须知标签
564
+        // 设置评标准/应价人须知标签
522 565
         if (this.agentInfo?.agentName) {
523 566
           if (this.agentInfo.agentName.includes('技术') && this.agentInfo.agentName.includes('询价')) {
524 567
             this.scoringTabLabel = '应价人须知'
525 568
           } else {
526
-            this.scoringTabLabel = '评标准'
569
+            this.scoringTabLabel = '评标准'
527 570
           }
528 571
         }
529 572
 
@@ -630,7 +673,6 @@ export default {
630 673
 
631 674
     // 选择话题(对话模式)
632 675
     async selectTopic(topic) {
633
-      console.log('选择话题:', topic)
634 676
       this.currentTopicId = topic.topicId
635 677
       this.chatTitle = topic.topic
636 678
 
@@ -722,11 +764,11 @@ export default {
722 764
     parseAnalysisResults(output) {
723 765
       if (!output) return
724 766
 
725
-      // 使用正则匹配【项目概况】【评标准】【应价人须知】【详细目录】
726
-      // 匹配项目概况(结束标记可以是评标准或应价人须知)
727
-      const overviewMatch = output.match(/【项目概况】\n([\s\S]*?)(?=\n\n【评标准】|\n\n【应价人须知】)/)
728
-      // 匹配评标准或应价人须知
729
-      const scoringMatch = output.match(/【评标准】\n([\s\S]*?)(?=\n\n【详细目录】)/)
767
+      // 使用正则匹配【项目概况】【评标准】【应价人须知】【详细目录】
768
+      // 匹配项目概况(结束标记可以是评标准或应价人须知)
769
+      const overviewMatch = output.match(/【项目概况】\n([\s\S]*?)(?=\n\n【评标准】|\n\n【应价人须知】)/)
770
+      // 匹配评标准或应价人须知
771
+      const scoringMatch = output.match(/【评标准】\n([\s\S]*?)(?=\n\n【详细目录】)/)
730 772
       const biddingMatch = output.match(/【应价人须知】\n([\s\S]*?)(?=\n\n【详细目录】)/)
731 773
       const directoryMatch = output.match(/【详细目录】\n([\s\S]*)/)
732 774
 
@@ -799,6 +841,23 @@ export default {
799 841
           this.currentTopicId = null
800 842
           this.chatMessages = []
801 843
           this.chatTitle = '智能体新对话'
844
+
845
+          // 任务模式下清空相关数据
846
+          if (this.currentMode === 'task') {
847
+            // 清空文档分析内容
848
+            this.analysisResults = {
849
+              overview: '',
850
+              scoring: '',
851
+              directory: ''
852
+            }
853
+            // 清空标书内容相关数据
854
+            this.directoryTree = []
855
+            this.currentFilename = ''
856
+            this.bidResult = ''
857
+            this.bidFilePath = ''
858
+            this.uploadedDocumentPath = ''
859
+            this.bidFileList = []
860
+          }
802 861
         }
803 862
 
804 863
         // 重新加载话题列表
@@ -862,7 +921,6 @@ export default {
862 921
           question: message
863 922
         })
864 923
         let content = JSON.parse(response.resultContent).content;
865
-        console.log(content);
866 924
 
867 925
         // 检查是否是默认的失败回复
868 926
         const defaultFailureMessage = '抱歉,我暂时无法回答这个问题。';
@@ -1342,11 +1400,20 @@ export default {
1342 1400
 
1343 1401
         // 重置流式生成状态
1344 1402
         this.isStreaming = true
1403
+        this.generationCompleted = false
1345 1404
         this.streamingContent = ''
1346 1405
         this.currentStreamingChapter = null
1347 1406
         this.chapterContents = {}
1348 1407
         this.bidResult = ''
1349 1408
 
1409
+        // 重置token用量
1410
+        this.tokenUsage = {
1411
+          promptTokens: 0,
1412
+          completionTokens: 0,
1413
+          totalTokens: 0
1414
+        }
1415
+        this.chapterTokenUsage = {}
1416
+
1350 1417
         // 创建FormData
1351 1418
         const formData = new FormData()
1352 1419
         formData.append('topicId', this.currentTopicId)
@@ -1370,8 +1437,6 @@ export default {
1370 1437
      * 使用API进行流式请求
1371 1438
      */
1372 1439
     fetchStreamGeneration(formData, bidTitle) {
1373
-      console.log('开始流式请求:', process.env.VUE_APP_BASE_API + '/llm/agent/streamGenerate')
1374
-
1375 1440
       const onMessage = (data, eventType) => {
1376 1441
         this.handleStreamMessage(data, eventType)
1377 1442
       }
@@ -1380,36 +1445,52 @@ export default {
1380 1445
         console.error('流式请求失败:', error)
1381 1446
         this.$message.error('生成标书失败: ' + error.message)
1382 1447
         this.isStreaming = false
1448
+        this.abortController = null
1383 1449
         this.$nextTick(() => {
1384 1450
           this.scrollToBottom()
1385 1451
         })
1386 1452
       }
1387 1453
 
1388 1454
       const onComplete = () => {
1389
-        console.log('流式请求完成')
1390 1455
         this.isStreaming = false
1391
-        this.$message.success('标书生成完成')
1392
-
1393
-        // 如果有生成的内容,保存到历史记录
1394
-        if (this.currentTopicId && this.streamingContent) {
1395
-          this.saveStreamingResult()
1396
-        }
1456
+        this.abortController = null
1457
+        // 完成消息由轮询方法在文档写入完成后显示
1397 1458
       }
1398 1459
 
1399
-      // 使用封装的API
1400
-      streamGenerateChapters(formData, onMessage, onError, onComplete)
1460
+      // 使用封装的API,并保存controller用于取消
1461
+      this.abortController = streamGenerateChapters(formData, onMessage, onError, onComplete)
1462
+    },
1463
+
1464
+    /**
1465
+     * 停止生成标书
1466
+     */
1467
+    stopGeneration() {
1468
+      if (this.abortController) {
1469
+        this.abortController.abort()
1470
+        this.abortController = null
1471
+        this.isStreaming = false
1472
+        this.generationCompleted = false
1473
+        this.$message.warning('已停止生成')
1474
+      }
1401 1475
     },
1402 1476
 
1403 1477
     /**
1404 1478
      * 处理流式消息 - 支持逐字输出
1405 1479
      */
1406 1480
     handleStreamMessage(data, eventType) {
1407
-      console.log('收到消息:', data, '事件类型:', eventType)
1408
-
1409 1481
       // 处理完成事件
1410 1482
       if (data.completed) {
1411 1483
         this.isStreaming = false
1484
+        this.generationCompleted = true
1412 1485
         this.currentStreamingChapter = null
1486
+        this.bidResult = ''
1487
+
1488
+        // 设置标书文件路径,使导出功能立即可用
1489
+        if (this.currentFilename) {
1490
+          this.bidFilePath = process.env.VUE_APP_BASE_API + '/profile/upload/agent/' + this.agentInfo.agentName + '/' + this.currentFilename
1491
+        }
1492
+
1493
+        this.$message.success('标书生成完成')
1413 1494
         return
1414 1495
       }
1415 1496
 
@@ -1425,6 +1506,11 @@ export default {
1425 1506
       const chapterIndex = data.chapterIndex
1426 1507
       const totalChapters = data.totalChapters
1427 1508
 
1509
+      // 获取token用量信息
1510
+      const promptTokens = data.promptTokens || 0
1511
+      const completionTokens = data.completionTokens || 0
1512
+      const totalTokens = data.totalTokens || 0
1513
+
1428 1514
       // 保存总章节数
1429 1515
       if (totalChapters) {
1430 1516
         this.totalChapters = totalChapters
@@ -1452,8 +1538,8 @@ export default {
1452 1538
       if (content) {
1453 1539
         this.streamingContent += content
1454 1540
 
1455
-        // 实时更新显示内容
1456
-        const formattedContent = this.streamingContent.replace(/\n/g, '<br>')
1541
+        // 使用 marked 进行 Markdown 格式化
1542
+        const formattedContent = marked(this.streamingContent)
1457 1543
         this.bidResult = `<h3 style="color: #409EFF; margin: 10px 0;">【${this.currentStreamingChapter}】</h3>
1458 1544
                         <div style="line-height: 1.8;">${formattedContent}</div>`
1459 1545
 
@@ -1469,19 +1555,49 @@ export default {
1469 1555
       }
1470 1556
       this.chapterContents[title] += content || ''
1471 1557
 
1472
-      // 如果是当前章节的最后一块内容,显示进度
1473
-      if (isLast) {
1474
-        console.log(`章节 "${title}" 生成完成,共 ${this.chapterContents[title].length} 字符`)
1558
+      // 实时更新并显示token用量(每个章节发送的是当前章节的累计值)
1559
+      if (promptTokens > 0 || completionTokens > 0 || totalTokens > 0) {
1560
+        // 保存当前章节的token用量(如果值更大则更新,避免重复累加)
1561
+        const currentChapterTokens = this.chapterTokenUsage[title] || { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1475 1562
 
1476
-        // 显示进度提示
1477
-        const progressMsg = `已完成: ${this.currentChapterIndex}/${this.totalChapters} 章`
1478
-        const progressElement = document.querySelector('.generation-progress')
1479
-        if (progressElement) {
1480
-          progressElement.textContent = progressMsg
1563
+        // 只在有新值时更新
1564
+        if (promptTokens > currentChapterTokens.promptTokens ||
1565
+          completionTokens > currentChapterTokens.completionTokens ||
1566
+          totalTokens > currentChapterTokens.totalTokens) {
1567
+
1568
+          this.chapterTokenUsage[title] = {
1569
+            promptTokens,
1570
+            completionTokens,
1571
+            totalTokens
1572
+          }
1573
+
1574
+          // 重新计算总token用量
1575
+          this.recalculateTotalTokenUsage()
1481 1576
         }
1482 1577
       }
1483 1578
     },
1484 1579
 
1580
+    /**
1581
+     * 重新计算总token用量
1582
+     */
1583
+    recalculateTotalTokenUsage() {
1584
+      let totalPrompt = 0
1585
+      let totalCompletion = 0
1586
+      let totalAll = 0
1587
+
1588
+      // 遍历所有章节的token用量
1589
+      for (const chapterTokens of Object.values(this.chapterTokenUsage)) {
1590
+        totalPrompt += chapterTokens.promptTokens || 0
1591
+        totalCompletion += chapterTokens.completionTokens || 0
1592
+        totalAll += chapterTokens.totalTokens || 0
1593
+      }
1594
+
1595
+      // 更新总token用量
1596
+      this.tokenUsage.promptTokens = totalPrompt
1597
+      this.tokenUsage.completionTokens = totalCompletion
1598
+      this.tokenUsage.totalTokens = totalAll
1599
+    },
1600
+
1485 1601
     /**
1486 1602
      * 滚动到底部(用于bid-content-area区域)
1487 1603
      */
@@ -1494,36 +1610,6 @@ export default {
1494 1610
       })
1495 1611
     },
1496 1612
 
1497
-    /**
1498
-     * 保存流式生成结果
1499
-     */
1500
-    async saveStreamingResult() {
1501
-      try {
1502
-        // 构建完整的标书内容
1503
-        let fullContent = ''
1504
-        for (const [title, content] of Object.entries(this.chapterContents)) {
1505
-          fullContent += `【${title}】\n${content}\n\n`
1506
-        }
1507
-
1508
-        // 保存到聊天记录
1509
-        const { addChat } = await import('@/api/llm/chat')
1510
-        await addChat({
1511
-          topicId: this.currentTopicId,
1512
-          input: '生成技术文件',
1513
-          output: fullContent,
1514
-          outputTime: parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
1515
-        })
1516
-
1517
-        console.log('流式生成结果已保存')
1518
-      } catch (error) {
1519
-        console.error('保存流式生成结果失败:', error)
1520
-      }
1521
-    },
1522
-
1523
-    // 在模板中需要添加一个进度显示(可选)
1524
-    // 可以在 bid-content-area 顶部添加:
1525
-    // <div v-if="isStreaming" class="generation-progress">正在生成中...</div>
1526
-
1527 1613
     // 在目录树中选中指定标题的节点
1528 1614
     selectDirectoryNode(title) {
1529 1615
       const findNode = (nodes) => {
@@ -1545,7 +1631,6 @@ export default {
1545 1631
 
1546 1632
     // 导出Word
1547 1633
     exportWord() {
1548
-      console.log(this.bidFilePath)
1549 1634
       if (!this.bidFilePath) {
1550 1635
         this.$message.warning('暂无文件可导出')
1551 1636
         return
@@ -1608,8 +1693,6 @@ export default {
1608 1693
           if (this.selectedNode && this.selectedNode.title === title) {
1609 1694
             this.selectedChapterContent = content
1610 1695
           }
1611
-          // 可以根据需要进行其他处理,比如存储到某个数据结构中
1612
-          console.log(`章节: ${title}`, content)
1613 1696
         }
1614 1697
       }
1615 1698
     },
@@ -1625,6 +1708,8 @@ export default {
1625 1708
     // 删除节点
1626 1709
     deleteNode(node) {
1627 1710
       this.removeNode(this.directoryTree, node)
1711
+      // 删除后重新编号
1712
+      this.renumberDirectory(node)
1628 1713
     },
1629 1714
 
1630 1715
     // 递归删除节点
@@ -1643,36 +1728,93 @@ export default {
1643 1728
       return false
1644 1729
     },
1645 1730
 
1731
+    // 重新编号目录树
1732
+    /**
1733
+ * 重新编号目录树,并可选删除指定节点
1734
+ * @param {string|null} deleteTarget - 需要删除的节点编号,不传则仅重新编号
1735
+ */
1736
+    renumberDirectory(deleteTarget = null) {
1737
+      // directoryTree 结构: [{ title: '6 技术文件', children: [二级标题...] }]
1738
+      if (!this.directoryTree || this.directoryTree.length === 0) return
1739
+
1740
+      const rootNode = this.directoryTree[0]
1741
+      if (!rootNode || !rootNode.children) return
1742
+
1743
+      // 提取根标题的前缀(如 "6")
1744
+      const rootMatch = rootNode.title.match(/^(\d+)/)
1745
+      const rootPrefix = rootMatch ? rootMatch[1] : '6'
1746
+
1747
+      // 递归重新编号节点
1748
+      const renumberNodes = (nodes, parentPrefix) => {
1749
+        // 1. 如果指定了删除目标,则倒序遍历删除目标节点及其子节点
1750
+        if (deleteTarget) {
1751
+          for (let i = nodes.length - 1; i >= 0; i--) {
1752
+            // 提取当前节点原始的编号部分
1753
+            const numMatch = nodes[i].title.match(/^(\d+(?:\.\d+)*)/)
1754
+            const originalNum = numMatch ? numMatch[1] : ''
1755
+
1756
+            // 如果原始编号等于目标,或者是目标的子级(以 目标. 开头),则移除
1757
+            if (originalNum === deleteTarget || originalNum.startsWith(`${deleteTarget}.`)) {
1758
+              nodes.splice(i, 1)
1759
+            }
1760
+          }
1761
+        }
1762
+
1763
+        // 2. 遍历剩余节点,进行重新编号
1764
+        nodes.forEach((node, index) => {
1765
+          const num = index + 1
1766
+          const newPrefix = `${parentPrefix}.${num}`
1767
+
1768
+          // 从当前标题中提取文本内容(匹配任意层级的编号格式)
1769
+          const numberPattern = /^\d+(?:\.\d+)*\s+(.+)/
1770
+          const titleMatch = node.title.match(numberPattern)
1771
+          const text = titleMatch ? titleMatch[1].trim() : node.title.replace(/^\d+(?:\.\d+)*\s*/, '').trim()
1772
+
1773
+          // 更新标题
1774
+          node.title = `${newPrefix} ${text}`
1775
+
1776
+          // 递归处理子节点
1777
+          if (node.children && node.children.length > 0) {
1778
+            renumberNodes(node.children, newPrefix)
1779
+          }
1780
+        })
1781
+      }
1782
+
1783
+      // 从二级标题开始重新编号
1784
+      renumberNodes(rootNode.children, rootPrefix)
1785
+    },
1786
+
1646 1787
     // 选择节点
1647 1788
     async selectNode(node) {
1648 1789
       this.selectedNode = node
1790
+      // 重置生成状态,确保 bidResult 能正常显示
1791
+      this.isStreaming = false
1792
+      this.generationCompleted = false
1649 1793
       // 更新文本域内容为已选中章节标题
1650 1794
       if (node && node.title) {
1651
-        // 先查询数据库中是否已有该节点的章节内容(只有存在topicId时才查询)
1652
-        if (this.currentTopicId) {
1795
+        console.log(node.title)
1796
+        // 判断是否为叶子节点(没有 children 或 children 为空),只有叶子节点才查询数据库
1797
+        const isLeafNode = !node.children || node.children.length === 0
1798
+        if (isLeafNode && this.currentTopicId) {
1653 1799
           try {
1654
-            const chatRes = await listChat({ topicId: this.currentTopicId })
1800
+            const chatRes = await listChat({ topicId: this.currentTopicId, input: node.title })
1655 1801
             if (chatRes.rows && chatRes.rows.length > 0) {
1656
-              // 查找input字段等于选中节点标题的记录
1657
-              const existingChat = chatRes.rows.find(chat => chat.input === node.title)
1658
-              if (existingChat && existingChat.output) {
1659
-                // 如果找到了,显示已保存的内容
1660
-                this.bidResult = `<h3 style="color: #409EFF; margin: 10px 0;">【${node.title}】(已保存)</h3>
1661
-                                 <div style="line-height: 1.8;">${existingChat.output.replace(/\n/g, '<br>')}</div>`
1662
-                this.inputMessage = '已选中章节: ' + node.title + '(已有内容)'
1663
-                return
1664
-              }
1802
+              // 如果找到了,显示已保存的内容
1803
+              this.bidResult = `<h3 style="color: #409EFF; margin: 10px 0;">【${node.title}】(已保存)</h3>
1804
+                                 <div style="line-height: 1.8;">${marked(chatRes.rows[0].output)}</div>`
1805
+              this.inputMessage = '已选中章节: ' + node.title + '(已有内容)'
1806
+              return
1665 1807
             }
1666 1808
           } catch (error) {
1667 1809
             console.error('查询章节内容失败:', error)
1668 1810
           }
1669 1811
         }
1670
-        // 如果没有找到已保存的内容,显示选中标题和暂无内容提示
1812
+        // 如果没有找到已保存的内容,或非叶子节点,显示选中标题和暂无内容提示
1671 1813
         this.inputMessage = '已选中章节: ' + node.title
1672 1814
         this.bidResult = `<h3 style="color: #409EFF; margin: 10px 0;">【${node.title}】</h3>
1673 1815
                           <div class="empty-bid-content" style="padding: 40px; text-align: center; color: #999;">
1674
-                           <p>暂无内容</p>
1675
-                           <p style="font-size: 12px; margin-top: 8px;">点击"生成标书"按钮生成该章节内容</p>
1816
+                           <p>${isLeafNode ? '暂无内容' : '该节点为目录节点,请选择子章节'}</p>
1817
+                           <p style="font-size: 12px; margin-top: 8px;">${isLeafNode ? '点击"生成标书"按钮生成该章节内容' : '请展开并选择具体章节'}</p>
1676 1818
                          </div>`
1677 1819
       }
1678 1820
     },
@@ -2617,6 +2759,42 @@ export default {
2617 2759
     gap: 15px;
2618 2760
   }
2619 2761
 
2762
+  .directory-toolbar {
2763
+    display: flex;
2764
+    align-items: center;
2765
+    justify-content: space-between;
2766
+    margin-bottom: 10px;
2767
+  }
2768
+
2769
+  .token-info-container {
2770
+    display: flex;
2771
+    flex-direction: row;
2772
+    align-items: center;
2773
+    gap: 15px;
2774
+    padding: 8px 12px;
2775
+    background: #f5f7fa;
2776
+    border-radius: 4px;
2777
+    font-size: 13px;
2778
+  }
2779
+
2780
+  .token-info-row {
2781
+    display: flex;
2782
+    align-items: center;
2783
+    gap: 5px;
2784
+  }
2785
+
2786
+  .token-label {
2787
+    color: #666;
2788
+    font-weight: 500;
2789
+  }
2790
+
2791
+  .token-value {
2792
+    color: #409EFF;
2793
+    font-weight: 600;
2794
+    min-width: 60px;
2795
+    text-align: right;
2796
+  }
2797
+
2620 2798
   .chapter-section {
2621 2799
     display: flex;
2622 2800
     flex-direction: column;

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

@@ -156,7 +156,7 @@ export default {
156 156
       dialogTitle: '',
157 157
       isModifyAgent: false,
158 158
       // 模型列表
159
-      modelList: [{name: "Qwen2.5-7B-Instruct"}]
159
+      modelList: [{name: "Qwen3-VL-8B-Instruct"}]
160 160
     }
161 161
   },
162 162
   mounted() {

+ 243
- 303
oa-ui/src/views/llm/chat/index.vue Просмотреть файл

@@ -196,6 +196,13 @@
196 196
                   <div class="message-bubble" :class="msg.type">
197 197
                     <div class="message-text" v-html="formatMessage(msg.text)"></div>
198 198
                     <div class="message-time">{{ formatMessageTime(msg.time) }}</div>
199
+                    <!-- AI消息的token用量统计 -->
200
+                    <div v-if="msg.type === 'ai' && msg.tokenUsage && msg.tokenUsage.totalTokens > 0"
201
+                      class="token-usage">
202
+                      <span class="token-label">消耗Token:</span>
203
+                      <span class="token-value">{{ msg.tokenUsage.totalTokens }}</span>
204
+                      <span class="token-breakdown">(输入: {{ msg.tokenUsage.promptTokens }}, 输出: {{ msg.tokenUsage.completionTokens }})</span>
205
+                    </div>
199 206
                   </div>
200 207
                   <!-- 用户消息的文件列表 -->
201 208
                   <div v-if="msg.type === 'user' && msg.chatId && msg.fileList && msg.fileList.length > 0"
@@ -290,74 +297,13 @@
290 297
 <script>
291 298
 import { parseTime } from "@/utils/ruoyi";
292 299
 import { Message as ElMessage, MessageBox as ElMessageBox } from 'element-ui'
293
-import { listTopic, getTopic, delTopic, addTopic, updateTopic } from "@/api/llm/topic";
294
-import { listChat, addChat, updateChat } from "@/api/llm/chat";
300
+import { listTopic, delTopic, addTopic, updateTopic } from "@/api/llm/topic";
301
+import { listChat, addChat } from "@/api/llm/chat";
295 302
 import { listDocument, uploadDocument } from "@/api/llm/document";
296
-import { getAnswer, getAnswerWithDocument, getAnswerStream, getAnswerWithDocumentStream } from "@/api/llm/session";
303
+import { getAnswerStream, getAnswerWithDocumentStream } from "@/api/llm/session";
297 304
 import logoImg from '@/assets/images/logo.png'
298 305
 import { marked } from 'marked';
299 306
 
300
-function createTypewriter(appender, options = {}) {
301
-  const intervalMs = typeof options.intervalMs === 'number' ? options.intervalMs : 25 // 约 40 字/秒
302
-  const maxCharsPerTick = typeof options.maxCharsPerTick === 'number' ? options.maxCharsPerTick : 1
303
-
304
-  let queue = ''
305
-  let timer = null
306
-  let ended = false
307
-  let onDrained = null
308
-
309
-  const tick = () => {
310
-    if (!queue) {
311
-      if (ended) {
312
-        if (timer) clearInterval(timer)
313
-        timer = null
314
-        if (onDrained) onDrained()
315
-      }
316
-      return
317
-    }
318
-
319
-    const n = Math.min(maxCharsPerTick, queue.length)
320
-    const chunk = queue.slice(0, n)
321
-    queue = queue.slice(n)
322
-    appender(chunk)
323
-  }
324
-
325
-  return {
326
-    push(text) {
327
-      if (!text) return
328
-      queue += text
329
-      if (!timer) timer = setInterval(tick, intervalMs)
330
-    },
331
-    end(cb) {
332
-      ended = true
333
-      onDrained = cb
334
-      if (!timer) timer = setInterval(tick, intervalMs)
335
-    },
336
-    stop() {
337
-      ended = true
338
-      queue = ''
339
-      if (timer) clearInterval(timer)
340
-      timer = null
341
-    }
342
-  }
343
-}
344
-
345
-function trimToLastSentenceEnd(text) {
346
-  const s = String(text || '')
347
-  // 句末标点:中文/英文句号、问号、叹号,或换行
348
-  const last = Math.max(
349
-    s.lastIndexOf('。'),
350
-    s.lastIndexOf('!'),
351
-    s.lastIndexOf('?'),
352
-    s.lastIndexOf('.'),
353
-    s.lastIndexOf('!'),
354
-    s.lastIndexOf('?'),
355
-    s.lastIndexOf('\n')
356
-  )
357
-  if (last === -1) return s.trim()
358
-  return s.slice(0, last + 1).trim()
359
-}
360
-
361 307
 export default {
362 308
   name: 'ChatView',
363 309
   data() {
@@ -439,6 +385,7 @@ export default {
439 385
             text: msg.output,
440 386
             time: msg.outputTime,
441 387
             id: msg.chatId ? msg.chatId + '_ai' : (msg.topicId + '_output_' + msg.outputTime),
388
+            tokenUsage: msg.tokenUsage
442 389
           })
443 390
         }
444 391
       }
@@ -619,7 +566,12 @@ export default {
619 566
         input: '',
620 567
         output: '',
621 568
         topicId: this.currentTopicId,
622
-        outputTime: this.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
569
+        outputTime: this.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}'),
570
+        tokenUsage: {
571
+          promptTokens: 0,
572
+          completionTokens: 0,
573
+          totalTokens: 0
574
+        }
623 575
       };
624 576
       this.chatMessages.push(aiMessage);
625 577
 
@@ -627,15 +579,6 @@ export default {
627 579
 
628 580
       this.isLoading = true;
629 581
 
630
-      const typewriter = createTypewriter((chunk) => {
631
-        aiMessage.output += chunk
632
-        aiMessage.outputTime = this.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
633
-        this.$nextTick(() => this.scrollToBottom())
634
-      }, {
635
-        intervalMs: 25,
636
-        maxCharsPerTick: 1
637
-      })
638
-
639 582
       const streamParams = {
640 583
         topicId: this.currentTopicId,
641 584
         question: userMessage.input
@@ -654,7 +597,10 @@ export default {
654 597
               return;
655 598
             }
656 599
 
657
-            typewriter.push(String(content))
600
+            // 直接追加内容,不使用打字机效果
601
+            aiMessage.output += String(content);
602
+            aiMessage.outputTime = that.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}');
603
+            that.$nextTick(() => that.scrollToBottom());
658 604
 
659 605
             if (window.responseTimeout) {
660 606
               clearTimeout(window.responseTimeout);
@@ -663,7 +609,6 @@ export default {
663 609
               if (that.isLoading) {
664 610
                 console.log('=== 响应超时强制结束 ===');
665 611
                 that.isLoading = false;
666
-                typewriter.stop()
667 612
                 if (window.responseTimeout) {
668 613
                   clearTimeout(window.responseTimeout);
669 614
                   window.responseTimeout = null;
@@ -687,7 +632,6 @@ export default {
687 632
               aiMessage.output += '\n\n[回答生成中断]';
688 633
             }
689 634
             that.isLoading = false;
690
-            typewriter.stop()
691 635
 
692 636
             that.$nextTick(() => {
693 637
               that.scrollToBottom();
@@ -702,73 +646,100 @@ export default {
702 646
               window.responseTimeout = null;
703 647
             }
704 648
 
705
-            typewriter.end(async () => {
706
-              aiMessage.output = trimToLastSentenceEnd(aiMessage.output)
707
-              try {
708
-                // 保存用户消息
709
-                const savedUserMessage = await addChat({
649
+            try {
650
+              // 保存用户消息
651
+              const savedUserMessage = await addChat({
652
+                ...that.chatMessages[userMessageIndex],
653
+                topicId: that.currentTopicId
654
+              });
655
+
656
+              // 保存AI消息
657
+              const savedMessage = await addChat({
658
+                ...aiMessage,
659
+                topicId: that.currentTopicId
660
+              });
661
+
662
+              if (savedUserMessage && savedUserMessage.chatId) {
663
+                that.$set(that.chatMessages, userMessageIndex, {
710 664
                   ...that.chatMessages[userMessageIndex],
711
-                  topicId: that.currentTopicId
665
+                  chatId: savedUserMessage.chatId
712 666
                 });
667
+              }
713 668
 
714
-                // 保存AI消息
715
-                const savedMessage = await addChat({
716
-                  ...aiMessage,
717
-                  topicId: that.currentTopicId
669
+              if (savedMessage && savedMessage.chatId) {
670
+                that.$set(that.chatMessages, messageIndex, {
671
+                  ...that.chatMessages[messageIndex],
672
+                  chatId: savedMessage.chatId
718 673
                 });
674
+              }
719 675
 
720
-                if (savedUserMessage && savedUserMessage.chatId) {
721
-                  that.$set(that.chatMessages, userMessageIndex, {
722
-                    ...that.chatMessages[userMessageIndex],
723
-                    chatId: savedUserMessage.chatId
724
-                  });
725
-                }
726
-
727
-                if (savedMessage && savedMessage.chatId) {
728
-                  that.$set(that.chatMessages, messageIndex, {
729
-                    ...that.chatMessages[messageIndex],
730
-                    chatId: savedMessage.chatId
731
-                  });
732
-                }
676
+              if (uploadedFileId) {
677
+                try {
678
+                  const fileResponse = await listDocument({ chatId: uploadedFileId });
679
+                  if (fileResponse.rows && fileResponse.rows.length > 0) {
680
+                    const messageChatId = that.chatMessages[messageIndex].chatId;
681
+                    const keyToUse = messageChatId || uploadedFileId;
682
+                    that.messageFileMap.set(keyToUse, fileResponse.rows);
733 683
 
734
-                if (uploadedFileId) {
735
-                  try {
736
-                    const fileResponse = await listDocument({ chatId: uploadedFileId });
737
-                    if (fileResponse.rows && fileResponse.rows.length > 0) {
738
-                      const messageChatId = that.chatMessages[messageIndex].chatId;
739
-                      const keyToUse = messageChatId || uploadedFileId;
740
-                      that.messageFileMap.set(keyToUse, fileResponse.rows);
741
-
742
-                      if (!that.chatMessages[messageIndex].chatId) {
743
-                        that.$set(that.chatMessages[messageIndex], 'chatId', uploadedFileId);
744
-                      }
684
+                    if (!that.chatMessages[messageIndex].chatId) {
685
+                      that.$set(that.chatMessages[messageIndex], 'chatId', uploadedFileId);
745 686
                     }
746
-                  } catch (error) {
747
-                    console.error('Failed to load files for new message:', error);
748 687
                   }
688
+                } catch (error) {
689
+                  console.error('Failed to load files for new message:', error);
749 690
                 }
750
-              } catch (error) {
751
-                console.error('保存消息失败:', error);
752 691
               }
692
+            } catch (error) {
693
+              console.error('保存消息失败:', error);
694
+            }
753 695
 
754
-              that.isLoading = false;
755
-              that.$nextTick(() => {
756
-                that.scrollToBottom();
757
-              });
758
-            })
696
+            that.isLoading = false;
697
+            that.$nextTick(() => {
698
+              that.scrollToBottom();
699
+            });
759 700
           }
760 701
         );
761 702
       } else {
762 703
         streamController = getAnswerStream(
763 704
           streamParams,
764
-          (content) => {
705
+          (jsonData, eventType) => {
765 706
             const that = this;
766 707
             if (!that.streamingStarted) that.streamingStarted = true;
767 708
 
768
-            if (!content || !String(content).trim()) {
709
+            // 根据事件类型处理不同的响应
710
+            if (eventType === 'error') {
711
+              // 错误事件
712
+              console.error('收到错误事件:', jsonData);
713
+              if (jsonData.errorMessage) {
714
+                aiMessage.output = jsonData.errorMessage;
715
+              }
769 716
               return;
770 717
             }
771 718
 
719
+            if (eventType === 'completed') {
720
+              // 完成事件,更新最终的token用量
721
+              if (jsonData.promptTokens !== undefined || jsonData.completionTokens !== undefined || jsonData.totalTokens !== undefined) {
722
+                aiMessage.tokenUsage.promptTokens = jsonData.promptTokens || aiMessage.tokenUsage.promptTokens;
723
+                aiMessage.tokenUsage.completionTokens = jsonData.completionTokens || aiMessage.tokenUsage.completionTokens;
724
+                aiMessage.tokenUsage.totalTokens = jsonData.totalTokens || aiMessage.tokenUsage.totalTokens;
725
+              }
726
+              return;
727
+            }
728
+
729
+            // 默认处理 answer 事件
730
+            if (!jsonData || !jsonData.content) {
731
+              return;
732
+            }
733
+
734
+            // 更新token用量
735
+            if (jsonData.promptTokens !== undefined || jsonData.completionTokens !== undefined || jsonData.totalTokens !== undefined) {
736
+              aiMessage.tokenUsage.promptTokens = jsonData.promptTokens || aiMessage.tokenUsage.promptTokens;
737
+              aiMessage.tokenUsage.completionTokens = jsonData.completionTokens || aiMessage.tokenUsage.completionTokens;
738
+              aiMessage.tokenUsage.totalTokens = jsonData.totalTokens || aiMessage.tokenUsage.totalTokens;
739
+            }
740
+
741
+            const content = jsonData.content;
742
+
772 743
             // 过滤不完整的句子(以逗号、顿号等结尾)
773 744
             const trimmed = String(content).trim();
774 745
             if (trimmed.length > 0 && trimmed.length < 30) {
@@ -778,7 +749,10 @@ export default {
778 749
               }
779 750
             }
780 751
 
781
-            typewriter.push(trimmed)
752
+            // 直接追加内容,不使用打字机效果
753
+            aiMessage.output += trimmed;
754
+            aiMessage.outputTime = that.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}');
755
+            that.$nextTick(() => that.scrollToBottom());
782 756
 
783 757
             if (window.responseTimeout) {
784 758
               clearTimeout(window.responseTimeout);
@@ -787,7 +761,6 @@ export default {
787 761
               if (that.isLoading) {
788 762
                 console.log('=== 响应超时强制结束 ===');
789 763
                 that.isLoading = false;
790
-                typewriter.stop()
791 764
                 if (window.responseTimeout) {
792 765
                   clearTimeout(window.responseTimeout);
793 766
                   window.responseTimeout = null;
@@ -811,7 +784,6 @@ export default {
811 784
               aiMessage.output += '\n\n[回答生成中断]';
812 785
             }
813 786
             that.isLoading = false;
814
-            typewriter.stop()
815 787
 
816 788
             that.$nextTick(() => {
817 789
               that.scrollToBottom();
@@ -826,53 +798,49 @@ export default {
826 798
               window.responseTimeout = null;
827 799
             }
828 800
 
829
-            typewriter.end(async () => {
830
-              aiMessage.output = trimToLastSentenceEnd(aiMessage.output)
831
-              try {
832
-                // 保存用户消息
833
-                const savedUserMessage = await addChat({
801
+            try {
802
+              // 保存用户消息
803
+              const savedUserMessage = await addChat({
804
+                ...that.chatMessages[userMessageIndex],
805
+                topicId: that.currentTopicId
806
+              });
807
+
808
+              // 保存AI消息
809
+              const savedMessage = await addChat({
810
+                ...aiMessage,
811
+                topicId: that.currentTopicId
812
+              });
813
+
814
+              if (savedUserMessage && savedUserMessage.chatId) {
815
+                that.$set(that.chatMessages, userMessageIndex, {
834 816
                   ...that.chatMessages[userMessageIndex],
835
-                  topicId: that.currentTopicId
817
+                  chatId: savedUserMessage.chatId
836 818
                 });
819
+              }
837 820
 
838
-                // 保存AI消息
839
-                const savedMessage = await addChat({
840
-                  ...aiMessage,
841
-                  topicId: that.currentTopicId
821
+              if (savedMessage && savedMessage.chatId) {
822
+                that.$set(that.chatMessages, messageIndex, {
823
+                  ...that.chatMessages[messageIndex],
824
+                  chatId: savedMessage.chatId
842 825
                 });
843
-
844
-                if (savedUserMessage && savedUserMessage.chatId) {
845
-                  that.$set(that.chatMessages, userMessageIndex, {
846
-                    ...that.chatMessages[userMessageIndex],
847
-                    chatId: savedUserMessage.chatId
848
-                  });
849
-                }
850
-
851
-                if (savedMessage && savedMessage.chatId) {
852
-                  that.$set(that.chatMessages, messageIndex, {
853
-                    ...that.chatMessages[messageIndex],
854
-                    chatId: savedMessage.chatId
855
-                  });
856
-                }
857
-              } catch (error) {
858
-                console.error('保存消息失败:', error);
859 826
               }
827
+            } catch (error) {
828
+              console.error('保存消息失败:', error);
829
+            }
860 830
 
861
-              that.isLoading = false;
862
-              that.$nextTick(() => {
863
-                that.scrollToBottom();
864
-              });
865
-            })
831
+            that.isLoading = false;
832
+            that.$nextTick(() => {
833
+              that.scrollToBottom();
834
+            });
866 835
           }
867 836
         );
868 837
       }
869 838
 
870 839
       window.currentChatController = streamController;
871 840
     },
872
-    //简化formatMessage方法,不再需要复杂的过滤
841
+    //格式化消息内容
873 842
     formatMessage(content) {
874
-      // 直接使用marked解析markdown内容,不需要额外过滤
875
-      // 因为后端和session.js已经处理干净了
843
+      // 直接使用marked解析markdown内容
876 844
       return marked(content);
877 845
     },
878 846
 
@@ -941,14 +909,9 @@ export default {
941 909
       }
942 910
     },
943 911
 
944
-    formatMessage(content) {
945
-      // 使用marked.js解析markdown内容
946
-      return marked(content);
947
-    },
948
-
949 912
     formatMessageTime(time) {
950 913
       if (!time) return '';
951
-      const date = this.parseTime(new Date(time), '{y}-{m}-{d}');
914
+      const date = this.parseTime(new Date(time), '{y}-{m}-{d} {h}:{i}:{s}');
952 915
       return date;
953 916
     },
954 917
 
@@ -1234,16 +1197,127 @@ export default {
1234 1197
               max-width: 100%;
1235 1198
 
1236 1199
               .message-text {
1237
-                margin-bottom: 4px;
1238
-                white-space: pre-wrap;
1239
-                overflow-wrap: break-word;
1240
-              }
1200
+                  margin-bottom: 4px;
1201
+                  white-space: pre-wrap;
1202
+                  overflow-wrap: break-word;
1203
+                  line-height: 1.6;
1204
+
1205
+                  /* 表格样式 */
1206
+                  table {
1207
+                    width: 100%;
1208
+                    border-collapse: collapse;
1209
+                    margin: 8px 0;
1210
+                    font-size: 14px;
1211
+                  }
1212
+
1213
+                  th,
1214
+                  td {
1215
+                    border: 1px solid #ddd;
1216
+                    padding: 8px 12px;
1217
+                    text-align: left;
1218
+                  }
1219
+
1220
+                  th {
1221
+                    background-color: #f5f5f5;
1222
+                    font-weight: 600;
1223
+                  }
1224
+
1225
+                  tr:nth-child(even) {
1226
+                    background-color: #f9f9f9;
1227
+                  }
1228
+
1229
+                  /* 列表样式 */
1230
+                  ul,
1231
+                  ol {
1232
+                    padding-left: 24px;
1233
+                    margin: 8px 0;
1234
+                  }
1235
+
1236
+                  li {
1237
+                    margin-bottom: 4px;
1238
+                  }
1239
+
1240
+                  /* 引用样式 */
1241
+                  blockquote {
1242
+                    border-left: 4px solid #1890ff;
1243
+                    padding: 8px 12px;
1244
+                    margin: 8px 0;
1245
+                    color: #666;
1246
+                    background-color: #f8f9fa;
1247
+                    border-radius: 0 4px 4px 0;
1248
+                  }
1249
+
1250
+                  /* 标题样式 */
1251
+                  h1, h2, h3, h4, h5, h6 {
1252
+                    margin: 12px 0 8px;
1253
+                    font-weight: 600;
1254
+                    color: #333;
1255
+                  }
1256
+
1257
+                  h1 { font-size: 20px; }
1258
+                  h2 { font-size: 18px; }
1259
+                  h3 { font-size: 16px; }
1260
+                  h4, h5, h6 { font-size: 14px; }
1261
+
1262
+                  /* 链接样式 */
1263
+                  a {
1264
+                    color: #1890ff;
1265
+                    text-decoration: none;
1266
+                  }
1267
+
1268
+                  a:hover {
1269
+                    text-decoration: underline;
1270
+                  }
1271
+
1272
+                  /* 粗体和斜体 */
1273
+                  strong {
1274
+                    font-weight: 600;
1275
+                  }
1276
+
1277
+                  em {
1278
+                    font-style: italic;
1279
+                  }
1280
+
1281
+                  /* 行内代码 */
1282
+                  code {
1283
+                    background-color: #f4f4f4;
1284
+                    padding: 2px 6px;
1285
+                    border-radius: 4px;
1286
+                    font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
1287
+                    font-size: 14px;
1288
+                    color: #e06c75;
1289
+                  }
1290
+                }
1241 1291
 
1242 1292
               .message-time {
1243 1293
                 font-size: 12px;
1244 1294
                 opacity: 0.7;
1245 1295
                 text-align: right;
1246 1296
               }
1297
+
1298
+              .token-usage {
1299
+                margin-top: 8px;
1300
+                padding-top: 8px;
1301
+                border-top: 1px dashed #e0e0e0;
1302
+                font-size: 12px;
1303
+                text-align: right;
1304
+                color: #666;
1305
+
1306
+                .token-label {
1307
+                  margin-right: 4px;
1308
+                }
1309
+
1310
+                .token-value {
1311
+                  color: #1890ff;
1312
+                  font-weight: 500;
1313
+                  margin-right: 4px;
1314
+                }
1315
+
1316
+                .token-breakdown {
1317
+                  color: #999;
1318
+                  font-size: 11px;
1319
+                }
1320
+              }
1247 1321
             }
1248 1322
 
1249 1323
             .message-files {
@@ -1597,138 +1671,4 @@ export default {
1597 1671
   }
1598 1672
 }
1599 1673
 
1600
-/* Markdown样式 */
1601
-.message-bubble>>>.message-text {
1602
-
1603
-  /* 标题样式 */
1604
-  h1 {
1605
-    font-size: 24px;
1606
-    font-weight: 700;
1607
-    margin: 16px 0 12px 0;
1608
-    color: #333;
1609
-  }
1610
-
1611
-  h2 {
1612
-    font-size: 20px;
1613
-    font-weight: 600;
1614
-    margin: 14px 0 10px 0;
1615
-    color: #333;
1616
-  }
1617
-
1618
-  h3 {
1619
-    font-size: 18px;
1620
-    font-weight: 600;
1621
-    margin: 12px 0 8px 0;
1622
-    color: #333;
1623
-  }
1624
-
1625
-  h4,
1626
-  h5,
1627
-  h6 {
1628
-    font-size: 16px;
1629
-    font-weight: 600;
1630
-    margin: 10px 0 6px 0;
1631
-    color: #333;
1632
-  }
1633
-
1634
-  /* 段落样式 */
1635
-  p {
1636
-    margin: 8px 0;
1637
-    line-height: 1.6;
1638
-  }
1639
-
1640
-  /* 列表样式 */
1641
-  ul,
1642
-  ol {
1643
-    margin: 8px 0;
1644
-    padding-left: 24px;
1645
-  }
1646
-
1647
-  li {
1648
-    margin: 4px 0;
1649
-    line-height: 1.6;
1650
-  }
1651
-
1652
-  /* 代码块样式 */
1653
-  pre {
1654
-    background-color: #f5f5f5;
1655
-    border-radius: 6px;
1656
-    padding: 12px;
1657
-    overflow-x: auto;
1658
-    margin: 12px 0;
1659
-    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1660
-    font-size: 14px;
1661
-    line-height: 1.5;
1662
-  }
1663
-
1664
-  code {
1665
-    background-color: #f0f0f0;
1666
-    border-radius: 3px;
1667
-    padding: 2px 6px;
1668
-    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1669
-    font-size: 14px;
1670
-  }
1671
-
1672
-  pre code {
1673
-    background-color: transparent;
1674
-    padding: 0;
1675
-  }
1676
-
1677
-  /* 引用样式 */
1678
-  blockquote {
1679
-    border-left: 4px solid #007bff;
1680
-    padding: 8px 12px;
1681
-    margin: 12px 0;
1682
-    background-color: #f8f9fa;
1683
-    color: #666;
1684
-    border-radius: 0 6px 6px 0;
1685
-  }
1686
-
1687
-  /* 表格样式 */
1688
-  table {
1689
-    border-collapse: collapse;
1690
-    width: 100%;
1691
-    margin: 12px 0;
1692
-  }
1693
-
1694
-  th,
1695
-  td {
1696
-    border: 1px solid #e9ecef;
1697
-    padding: 8px 12px;
1698
-    text-align: left;
1699
-  }
1700
-
1701
-  th {
1702
-    background-color: #f8f9fa;
1703
-    font-weight: 600;
1704
-  }
1705
-
1706
-  /* 链接样式 */
1707
-  a {
1708
-    color: #007bff;
1709
-    text-decoration: none;
1710
-    transition: color 0.2s ease;
1711
-  }
1712
-
1713
-  a:hover {
1714
-    color: #0056b3;
1715
-    text-decoration: underline;
1716
-  }
1717
-
1718
-  /* 粗体和斜体样式 */
1719
-  strong {
1720
-    font-weight: 600;
1721
-  }
1722
-
1723
-  em {
1724
-    font-style: italic;
1725
-  }
1726
-
1727
-  /* 水平线样式 */
1728
-  hr {
1729
-    border: none;
1730
-    border-top: 1px solid #e9ecef;
1731
-    margin: 20px 0;
1732
-  }
1733
-}
1734 1674
 </style>

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

@@ -266,25 +266,21 @@
266 266
                     </div>
267 267
 
268 268
                     <div class="message-time">{{ message.time }}</div>
269
-                  </div>
270
-                </div>
271
-              </div>
272 269
 
273
-              <!-- 加载状态:仅在尚未收到首段流内容时显示,避免出现两个AI气泡 -->
274
-              <div v-if="isSending && !streamingStarted" class="message-item ai">
275
-                <div class="message-avatar">
276
-                  <div class="ai-avatar">
277
-                    🤖
278
-                  </div>
279
-                </div>
280
-                <div class="message-content">
281
-                  <div class="message-bubble ai">
282
-                    <div class="loading-dots">
283
-                      <span></span><span></span><span></span>
270
+                    <!-- Token用量统计 -->
271
+                    <div v-if="message.type === 'ai' && message.tokenUsage"
272
+                      class="token-usage">
273
+                      <div class="token-info">
274
+                        <span class="token-label">消耗Token:</span>
275
+                        <span class="token-value">{{ message.tokenUsage.totalTokens || 0 }}</span>
276
+                        <span class="token-breakdown">(输入: {{ message.tokenUsage.promptTokens || 0 }}, 输出: {{ message.tokenUsage.completionTokens || 0 }})</span>
277
+                      </div>
284 278
                     </div>
285 279
                   </div>
286 280
                 </div>
287 281
               </div>
282
+
283
+              <!-- 注意:加载状态已整合到AI消息的typing样式中,不需要单独的加载div -->
288 284
             </div>
289 285
           </div>
290 286
 
@@ -379,67 +375,6 @@ import { listKnowledge, listKnowLedgeByCollectionName, addKnowledge, updateKnowl
379 375
 import { getAnswer, getAnswerStream, getContextFile } from '@/api/llm/rag';
380 376
 import { marked } from 'marked';
381 377
 
382
-function createTypewriter(appender, options = {}) {
383
-  const intervalMs = typeof options.intervalMs === 'number' ? options.intervalMs : 25 // 约 40 字/秒
384
-  const maxCharsPerTick = typeof options.maxCharsPerTick === 'number' ? options.maxCharsPerTick : 1
385
-
386
-  let queue = ''
387
-  let timer = null
388
-  let ended = false
389
-  let onDrained = null
390
-
391
-  const tick = () => {
392
-    if (!queue) {
393
-      if (ended) {
394
-        if (timer) clearInterval(timer)
395
-        timer = null
396
-        if (onDrained) onDrained()
397
-      }
398
-      return
399
-    }
400
-
401
-    const n = Math.min(maxCharsPerTick, queue.length)
402
-    const chunk = queue.slice(0, n)
403
-    queue = queue.slice(n)
404
-    appender(chunk)
405
-  }
406
-
407
-  return {
408
-    push(text) {
409
-      if (!text) return
410
-      queue += text
411
-      if (!timer) timer = setInterval(tick, intervalMs)
412
-    },
413
-    end(cb) {
414
-      ended = true
415
-      onDrained = cb
416
-      if (!timer) timer = setInterval(tick, intervalMs)
417
-    },
418
-    stop() {
419
-      ended = true
420
-      queue = ''
421
-      if (timer) clearInterval(timer)
422
-      timer = null
423
-    }
424
-  }
425
-}
426
-
427
-function trimToLastSentenceEnd(text) {
428
-  const s = String(text || '')
429
-  // 句末标点:中文/英文句号、问号、叹号,或换行
430
-  const last = Math.max(
431
-    s.lastIndexOf('。'),
432
-    s.lastIndexOf('!'),
433
-    s.lastIndexOf('?'),
434
-    s.lastIndexOf('.'),
435
-    s.lastIndexOf('!'),
436
-    s.lastIndexOf('?'),
437
-    s.lastIndexOf('\n')
438
-  )
439
-  if (last === -1) return s.trim()
440
-  return s.slice(0, last + 1).trim()
441
-}
442
-
443 378
 export default {
444 379
   name: 'KnowledgeManager',
445 380
   // 处理引用文件的显示状态
@@ -752,6 +687,19 @@ export default {
752 687
         this.stopGenerating();
753 688
       }
754 689
 
690
+      // 立即设置为发送状态,防止重复发送
691
+      this.isSending = true;
692
+
693
+      // 如果用户快速发送多条消息,取消之前的请求(放在发送新请求之前)
694
+      if (window.currentController) {
695
+        window.currentController.abort();
696
+        window.currentController = null;
697
+      }
698
+      if (window.responseTimeout) {
699
+        clearTimeout(window.responseTimeout);
700
+        window.responseTimeout = null;
701
+      }
702
+
755 703
       const userMessage = {
756 704
         type: 'user',
757 705
         content: this.chatInput.trim(),
@@ -761,7 +709,6 @@ export default {
761 709
       this.chatMessages.push(userMessage);
762 710
       const currentInput = this.chatInput;
763 711
       this.chatInput = '';
764
-      this.isSending = true;
765 712
       this.streamingStarted = false;
766 713
 
767 714
       // 滚动到底部
@@ -775,33 +722,70 @@ export default {
775 722
         content: '',
776 723
         time: parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}'),
777 724
         references: [], // 引用的文件信息
778
-        showAllReferences: false // 是否显示所有引用文件
725
+        showAllReferences: false, // 是否显示所有引用文件
726
+        tokenUsage: {
727
+          promptTokens: 0,
728
+          completionTokens: 0,
729
+          totalTokens: 0
730
+        }
779 731
       };
780 732
       this.chatMessages.push(aiMessage);
781 733
 
782
-      const typewriter = createTypewriter((chunk) => {
783
-        aiMessage.content += chunk
784
-        aiMessage.time = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
785
-        this.$nextTick(() => this.scrollToBottom())
786
-      }, {
787
-        intervalMs: 25,
788
-        maxCharsPerTick: 1
789
-      })
790
-
791 734
       // 使用流式API获取回答
792 735
       const eventSource = getAnswerStream(
793 736
         currentInput,
794 737
         this.selectedKnowledge.collectionName,
795 738
         // onMessage: 接收到每个字符时的回调
796
-        (content) => {
739
+        (jsonData, eventType) => {
797 740
           const that = this;
798 741
           if (!that.streamingStarted) that.streamingStarted = true;
799 742
 
800
-          if (!content || !String(content).trim()) {
743
+          // 根据事件类型处理不同的响应
744
+          if (eventType === 'error') {
745
+            // 错误事件
746
+            console.error('收到错误事件:', jsonData);
747
+            if (jsonData.errorMessage) {
748
+              aiMessage.content = jsonData.errorMessage;
749
+            }
801 750
             return;
802 751
           }
803 752
 
804
-          typewriter.push(String(content))
753
+          if (eventType === 'completed') {
754
+            // 完成事件,更新最终的token用量
755
+            if (jsonData.promptTokens !== undefined || jsonData.completionTokens !== undefined || jsonData.totalTokens !== undefined) {
756
+              const index = that.chatMessages.length - 1;
757
+              if (that.chatMessages[index] && that.chatMessages[index].tokenUsage) {
758
+                that.$set(that.chatMessages[index].tokenUsage, 'promptTokens', jsonData.promptTokens || 0);
759
+                that.$set(that.chatMessages[index].tokenUsage, 'completionTokens', jsonData.completionTokens || 0);
760
+                that.$set(that.chatMessages[index].tokenUsage, 'totalTokens', jsonData.totalTokens || 0);
761
+              }
762
+            }
763
+            return;
764
+          }
765
+
766
+          // 默认处理 answer 事件
767
+          if (!jsonData) {
768
+            return;
769
+          }
770
+
771
+          // 更新token用量
772
+          if (jsonData.promptTokens !== undefined || jsonData.completionTokens !== undefined || jsonData.totalTokens !== undefined) {
773
+            const index = that.chatMessages.length - 1;
774
+            if (that.chatMessages[index] && that.chatMessages[index].tokenUsage) {
775
+              that.$set(that.chatMessages[index].tokenUsage, 'promptTokens', jsonData.promptTokens || that.chatMessages[index].tokenUsage.promptTokens);
776
+              that.$set(that.chatMessages[index].tokenUsage, 'completionTokens', jsonData.completionTokens || that.chatMessages[index].tokenUsage.completionTokens);
777
+              that.$set(that.chatMessages[index].tokenUsage, 'totalTokens', jsonData.totalTokens || that.chatMessages[index].tokenUsage.totalTokens);
778
+            }
779
+          }
780
+
781
+          // 获取内容
782
+          const content = jsonData.content;
783
+          if (content && content !== '[DONE]') {
784
+            // 直接追加内容,不使用打字机效果
785
+            aiMessage.content += String(content);
786
+            aiMessage.time = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}');
787
+            that.$nextTick(() => that.scrollToBottom());
788
+          }
805 789
 
806 790
           if (window.responseTimeout) {
807 791
             clearTimeout(window.responseTimeout);
@@ -810,7 +794,6 @@ export default {
810 794
             if (that.isSending) {
811 795
               console.log('=== 响应超时强制结束 ===');
812 796
               that.isSending = false;
813
-              typewriter.stop()
814 797
               if (window.responseTimeout) {
815 798
                 clearTimeout(window.responseTimeout);
816 799
                 window.responseTimeout = null;
@@ -835,14 +818,13 @@ export default {
835 818
             aiMessage.content += '\n\n[回答生成中断]';
836 819
           }
837 820
           that.isSending = false;
838
-          typewriter.stop()
839 821
 
840 822
           that.$nextTick(() => {
841 823
             that.scrollToBottom();
842 824
           });
843 825
         },
844 826
         // onComplete: 回答完成时的回调
845
-        () => {
827
+        async () => {
846 828
           const that = this;
847 829
           console.log('=== 回答完成 ===');
848 830
 
@@ -851,38 +833,28 @@ export default {
851 833
             window.responseTimeout = null;
852 834
           }
853 835
 
854
-          typewriter.end(async () => {
855
-            aiMessage.content = trimToLastSentenceEnd(aiMessage.content)
856
-            try {
857
-              // 获取上下文引用文件
858
-              const response = await getContextFile(currentInput, that.selectedKnowledge.collectionName);
859
-              console.log('=== 上下文文件 ===', response)
860
-              if (response && Array.isArray(response)) {
861
-                aiMessage.references = response.map(item => ({
862
-                  fileName: item.file_name,
863
-                  similarity: item.score,
864
-                  content: item.content
865
-                }));
866
-                that.$forceUpdate(); // 强制更新以显示引用文件
867
-              }
868
-            } catch (error) {
869
-              console.error('获取上下文文件失败:', error);
836
+          try {
837
+            // 获取上下文引用文件
838
+            const response = await getContextFile(currentInput, that.selectedKnowledge.collectionName);
839
+            console.log('=== 上下文文件 ===', response)
840
+            if (response && Array.isArray(response)) {
841
+              aiMessage.references = response.map(item => ({
842
+                fileName: item.file_name,
843
+                similarity: item.score,
844
+                content: item.content
845
+              }));
846
+              that.$forceUpdate(); // 强制更新以显示引用文件
870 847
             }
848
+          } catch (error) {
849
+            console.error('获取上下文文件失败:', error);
850
+          }
871 851
 
872
-            that.isSending = false;
873
-            that.$nextTick(() => {
874
-              that.scrollToBottom();
875
-            });
876
-          })
852
+          that.isSending = false;
853
+          that.$nextTick(() => {
854
+            that.scrollToBottom();
855
+          });
877 856
         }
878 857
       );
879
-      // 如果用户快速发送多条消息,取消之前的请求
880
-      if (window.currentController) {
881
-        window.currentController.abort();
882
-      }
883
-      if (window.responseTimeout) {
884
-        clearTimeout(window.responseTimeout);
885
-      }
886 858
 
887 859
       window.currentController = eventSource;
888 860
     },
@@ -914,6 +886,13 @@ export default {
914 886
         window.currentController.abort();
915 887
         window.currentController = null;
916 888
       }
889
+
890
+      // 如果最后一条消息是空的AI消息,移除它(防止重复消息)
891
+      const lastMessage = this.chatMessages[this.chatMessages.length - 1];
892
+      if (lastMessage && lastMessage.type === 'ai' && !lastMessage.content) {
893
+        this.chatMessages.pop();
894
+      }
895
+
917 896
       this.isSending = false;
918 897
     },
919 898
 
@@ -1839,15 +1818,58 @@ export default {
1839 1818
     font-size: 12px;
1840 1819
     opacity: 0.7;
1841 1820
   }
1821
+
1822
+  .token-usage {
1823
+    margin-top: 8px;
1824
+    padding-top: 8px;
1825
+    border-top: 1px dashed #e0e0e0;
1826
+  }
1827
+
1828
+  .token-info {
1829
+    display: flex;
1830
+    align-items: center;
1831
+    font-size: 12px;
1832
+    color: #666;
1833
+  }
1834
+
1835
+  .token-label {
1836
+    margin-right: 4px;
1837
+    color: #999;
1838
+  }
1839
+
1840
+  .token-value {
1841
+    font-weight: 600;
1842
+    color: #409eff;
1843
+    margin-right: 8px;
1844
+  }
1845
+
1846
+  .token-breakdown {
1847
+    color: #999;
1848
+    font-size: 11px;
1849
+  }
1842 1850
 }
1843 1851
 
1844
-// 打字机效果的光标 - 只在正在生成时显示
1852
+// 打字机效果的光标 - 只在正在生成且有内容时显示
1845 1853
 .message-bubble.ai.typing .message-text:after {
1846 1854
   content: '|';
1847 1855
   animation: blink 1s infinite;
1848 1856
   color: #409eff;
1849 1857
 }
1850 1858
 
1859
+// 加载状态:内容为空时显示加载动画
1860
+.message-bubble.ai.typing:empty::before {
1861
+  content: '';
1862
+  display: inline-block;
1863
+  width: 60px;
1864
+  height: 20px;
1865
+  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 20'%3E%3Ccircle cx='10' cy='10' r='4' fill='%23409eff'%3E%3Canimate attributeName='opacity' values='1;0.3;1' dur='1s' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='30' cy='10' r='4' fill='%23409eff'%3E%3Canimate attributeName='opacity' values='0.3;1;0.3' dur='1s' begin='0.2s' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='50' cy='10' r='4' fill='%23409eff'%3E%3Canimate attributeName='opacity' values='0.3;1;0.3' dur='1s' begin='0.4s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/svg%3E") no-repeat center;
1866
+  background-size: contain;
1867
+}
1868
+
1869
+.message-bubble.ai.typing:empty .message-text {
1870
+  display: none;
1871
+}
1872
+
1851 1873
 @keyframes blink {
1852 1874
 
1853 1875
   0%,

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