lamphua hace 12 horas
padre
commit
5036a0efec
Se han modificado 23 ficheros con 809 adiciones y 556 borrados
  1. 6
    7
      oa-back/ruoyi-admin/src/main/resources/application.yml
  2. 10
    1
      oa-back/ruoyi-agent/pom.xml
  3. 26
    22
      oa-back/ruoyi-agent/src/main/java/com/ruoyi/agent/service/McpServerConfig.java
  4. 26
    16
      oa-back/ruoyi-agent/src/main/java/com/ruoyi/agent/service/impl/McpServiceImpl.java
  5. 3
    1
      oa-back/ruoyi-agent/src/main/resources/application.yml
  6. 1
    1
      oa-back/ruoyi-llm/pom.xml
  7. 6
    6
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/McpController.java
  8. 9
    126
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/SessionController.java
  9. 18
    0
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/ISessionService.java
  10. 21
    6
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/LangChainMilvusServiceImpl.java
  11. 20
    5
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/MilvusServiceImpl.java
  12. 269
    0
      oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/SessionServiceImpl.java
  13. 1
    1
      oa-back/ruoyi-system/pom.xml
  14. 21
    6
      oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/CmcAgentServiceImpl.java
  15. 15
    0
      oa-back/ruoyi-system/src/main/java/com/ruoyi/oa/domain/CmcTrainApproval.java
  16. 1
    0
      oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcCarMapper.xml
  17. 1
    0
      oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcStudyMapper.xml
  18. 7
    1
      oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcTrainApprovalMapper.xml
  19. 116
    232
      oa-ui/src/api/llm/session.js
  20. 14
    14
      oa-ui/src/views/flowable/form/changeForm.vue
  21. 11
    3
      oa-ui/src/views/flowable/form/oa/studyForm.vue
  22. 191
    92
      oa-ui/src/views/llm/chat/index.vue
  23. 16
    16
      oa-ui/src/views/oa/study/approval.vue

+ 6
- 7
oa-back/ruoyi-admin/src/main/resources/application.yml Ver fichero

@@ -8,12 +8,16 @@ cmc:
8 8
   copyrightYear: 2023
9 9
   # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
10 10
 #  profile: /home/cmc/Java-server/综合办公
11
-  profile: /home/cmc/projects/cmc-oa
12
-#  profile: D:/ruoyi/uploadPath
11
+#  profile: /home/cmc/projects/cmc-oa
12
+  profile: D:/ruoyi/uploadPath
13 13
   # 获取ip地址开关
14 14
   addressEnabled: false
15 15
   # 验证码类型 math 数字计算 char 字符验证
16 16
   captchaType: math
17
+  llmService:
18
+    url: http://192.168.28.196:8000/v1/chat/completions
19
+  milvusService:
20
+    url: http://192.168.28.196:19530
17 21
 
18 22
 # 开发环境配置
19 23
 server:
@@ -139,8 +143,3 @@ flowable:
139 143
   database-schema-update: true
140 144
   # 关闭定时任务JOB
141 145
   async-executor-activate: false
142
-
143
-llmService:
144
-  url: http://192.168.28.196:8000/v1/chat/completions
145
-milvusService:
146
-  url: http://192.168.28.196:19530

+ 10
- 1
oa-back/ruoyi-agent/pom.xml Ver fichero

@@ -17,7 +17,7 @@
17 17
     </description>
18 18
 
19 19
     <properties>
20
-        <solon.version>3.5.1</solon.version>
20
+        <solon.version>3.9.5</solon.version>
21 21
     </properties>
22 22
 
23 23
     <dependencies>
@@ -44,6 +44,15 @@
44 44
             <artifactId>solon-ai-mcp</artifactId>
45 45
         </dependency>
46 46
 
47
+        <dependency>
48
+            <groupId>org.noear</groupId>
49
+            <artifactId>solon-ai-agent</artifactId>
50
+        </dependency>
51
+        <dependency>
52
+            <groupId>org.slf4j</groupId>
53
+            <artifactId>slf4j-api</artifactId>
54
+            <version>1.7.36</version>
55
+        </dependency>
47 56
         <dependency>
48 57
             <groupId>org.apache.poi</groupId>
49 58
             <artifactId>poi</artifactId>

+ 26
- 22
oa-back/ruoyi-agent/src/main/java/com/ruoyi/agent/service/McpServerConfig.java Ver fichero

@@ -2,12 +2,14 @@ package com.ruoyi.agent.service;
2 2
 
3 3
 import org.noear.solon.Solon;
4 4
 import org.noear.solon.ai.chat.tool.MethodToolProvider;
5
+import org.noear.solon.ai.mcp.server.IMcpServerEndpoint;
5 6
 import org.noear.solon.ai.mcp.server.McpServerEndpointProvider;
6 7
 import org.noear.solon.ai.mcp.server.annotation.McpServerEndpoint;
7
-import org.noear.solon.ai.mcp.server.prompt.MethodPromptProvider;
8
-import org.noear.solon.ai.mcp.server.resource.MethodResourceProvider;
8
+import org.noear.solon.ai.chat.prompt.MethodPromptProvider;
9
+import org.noear.solon.ai.chat.resource.MethodResourceProvider;
9 10
 import org.noear.solon.web.servlet.SolonServletFilter;
10 11
 import org.springframework.aop.support.AopUtils;
12
+import org.springframework.beans.factory.annotation.Autowired;
11 13
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
12 14
 import org.springframework.context.annotation.Bean;
13 15
 import org.springframework.context.annotation.Configuration;
@@ -22,9 +24,12 @@ import java.util.List;
22 24
  * */
23 25
 @Configuration
24 26
 public class McpServerConfig {
27
+    @Autowired(required=false)
28
+    private List<IMcpServerEndpoint> serverEndpoints;
25 29
     @PostConstruct
26 30
     public void start() {
27 31
         Solon.start(McpServerConfig.class, new String[]{"--cfg=application.yml"});
32
+        springCom2Endpoint();
28 33
     }
29 34
 
30 35
     @PreDestroy
@@ -34,32 +39,31 @@ public class McpServerConfig {
34 39
         }
35 40
     }
36 41
 
37
-    @Bean
38
-    public McpServerConfig init(List<IMcpService> serverEndpoints) {
39
-        //提取实现容器里 IMcpService 接口的 bean ,并注册为服务端点
40
-        for (IMcpService serverEndpoint : serverEndpoints) {
41
-            Class<?> serverEndpointClz = AopUtils.getTargetClass(serverEndpoint);
42
-            McpServerEndpoint anno = AnnotationUtils.findAnnotation(serverEndpointClz, McpServerEndpoint.class);
42
+    //Spring 组件转为端点
43
+    protected void springCom2Endpoint() {
44
+        if (serverEndpoints != null) {
45
+            //提取实现容器里 IMcpServerEndpoint 接口的 bean ,并注册为服务端点
46
+            for (IMcpServerEndpoint serverEndpoint : serverEndpoints) {
47
+                Class<?> serverEndpointClz = AopUtils.getTargetClass(serverEndpoint);
48
+                McpServerEndpoint anno = AnnotationUtils.findAnnotation(serverEndpointClz, McpServerEndpoint.class);
43 49
 
44
-            if (anno == null) {
45
-                continue;
46
-            }
50
+                if (anno == null) {
51
+                    continue;
52
+                }
47 53
 
48
-            McpServerEndpointProvider serverEndpointProvider = McpServerEndpointProvider.builder()
49
-                    .from(serverEndpointClz, anno)
50
-                    .build();
54
+                McpServerEndpointProvider serverEndpointProvider = McpServerEndpointProvider.builder()
55
+                        .from(serverEndpointClz, anno)
56
+                        .build();
51 57
 
52
-            serverEndpointProvider.addTool(new MethodToolProvider(serverEndpointClz, serverEndpoint));
53
-            serverEndpointProvider.addResource(new MethodResourceProvider(serverEndpointClz, serverEndpoint));
54
-            serverEndpointProvider.addPrompt(new MethodPromptProvider(serverEndpointClz, serverEndpoint));
58
+                serverEndpointProvider.addTool(new MethodToolProvider(serverEndpointClz, serverEndpoint));
59
+                serverEndpointProvider.addResource(new MethodResourceProvider(serverEndpointClz, serverEndpoint));
60
+                serverEndpointProvider.addPrompt(new MethodPromptProvider(serverEndpointClz, serverEndpoint));
55 61
 
56
-            serverEndpointProvider.postStart();
62
+                serverEndpointProvider.postStart();
57 63
 
58
-            //可以再把 serverEndpointProvider 手动转入 SpringBoot 容器
64
+                //可以再把 serverEndpointProvider 手动转入 SpringBoot 容器
65
+            }
59 66
         }
60
-
61
-        //为了能让这个 init 能正常运行
62
-        return this;
63 67
     }
64 68
 
65 69
     @Bean

+ 26
- 16
oa-back/ruoyi-agent/src/main/java/com/ruoyi/agent/service/impl/McpServiceImpl.java Ver fichero

@@ -23,11 +23,14 @@ import io.milvus.v2.service.vector.request.SearchReq;
23 23
 import io.milvus.v2.service.vector.request.data.BaseVector;
24 24
 import io.milvus.v2.service.vector.request.data.FloatVec;
25 25
 import io.milvus.v2.service.vector.response.SearchResp;
26
+import io.modelcontextprotocol.server.McpServer;
27
+import io.modelcontextprotocol.spec.McpServerSession;
26 28
 import org.apache.poi.hwpf.HWPFDocument;
27 29
 import org.apache.poi.hwpf.usermodel.Paragraph;
28 30
 import org.apache.poi.hwpf.usermodel.Range;
29 31
 import org.apache.poi.xwpf.usermodel.*;
30 32
 import org.apache.xmlbeans.XmlCursor;
33
+import org.noear.solon.Solon;
31 34
 import org.noear.solon.ai.annotation.ToolMapping;
32 35
 import org.noear.solon.ai.chat.ChatModel;
33 36
 import org.noear.solon.ai.chat.ChatResponse;
@@ -42,12 +45,13 @@ import org.springframework.beans.factory.annotation.Value;
42 45
 import org.springframework.stereotype.Service;
43 46
 
44 47
 import javax.annotation.PostConstruct;
48
+import javax.annotation.PreDestroy;
45 49
 import java.io.*;
46 50
 import java.sql.*;
47 51
 import java.util.*;
48 52
 
49 53
 @Service
50
-@McpServerEndpoint(channel = McpChannel.SSE, mcpEndpoint = "/mcp/sse")
54
+@McpServerEndpoint(channel = McpChannel.STREAMABLE_STATELESS , mcpEndpoint = "/mcp/sse")
51 55
 public class McpServiceImpl implements IMcpService {
52 56
 
53 57
     private static final EmbeddingModel embeddingModel = new BgeSmallZhV15EmbeddingModel();
@@ -58,20 +62,11 @@ public class McpServiceImpl implements IMcpService {
58 62
     @Value("${cmc.milvusService.url}")
59 63
     private String milvusServiceUrl;
60 64
 
61
-    @Value("${cmc.mysqlService.jdbcUrl}")
62
-    private String url ;
63
-
64
-    @Value("${cmc.mysqlService.username}")
65
-    private String user;
66
-
67
-    @Value("${cmc.mysqlService.password}")
68
-    private String password;
69
-
70 65
     @Value("${cmc.profile}")
71 66
     private String profile;
72 67
 
73 68
     private MilvusClientV2 milvusClient;
74
-    
69
+
75 70
     /**
76 71
      * 初始化 Milvus 客户端
77 72
      */
@@ -80,11 +75,26 @@ public class McpServiceImpl implements IMcpService {
80 75
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
81 76
             throw new IllegalStateException("Milvus service URL 未配置");
82 77
         }
83
-        milvusClient = new MilvusClientV2(
84
-                ConnectConfig.builder()
85
-                        .uri(milvusServiceUrl)
86
-                        .build());
78
+//        milvusClient = new MilvusClientV2(
79
+//                ConnectConfig.builder()
80
+//                        .uri(milvusServiceUrl)
81
+//                        .build());
82
+    }    
83
+
84
+    @PreDestroy
85
+    public void destroyMilvusClient() {
86
+        if (milvusClient != null) {
87
+            try {
88
+                // 关闭 Milvus 客户端,释放 gRPC 通道
89
+                milvusClient.close();
90
+                System.out.println("Milvus client closed successfully");
91
+            } catch (Exception e) {
92
+                System.err.println("Error closing Milvus client: " + e.getMessage());
93
+                e.printStackTrace();
94
+            }
95
+        }
87 96
     }
97
+
88 98
     /**
89 99
      * 调用LLM+RAG(外部文件+知识库)生成回答
90 100
      */
@@ -126,7 +136,7 @@ public class McpServiceImpl implements IMcpService {
126 136
             throw new RuntimeException("MySQL 驱动未找到", e);
127 137
         }
128 138
 
129
-        try (Connection conn = DriverManager.getConnection(url, user, password);
139
+        try (Connection conn = DriverManager.getConnection(Solon.cfg().getProperty("cmc.mysqlService.jdbcUrl"),Solon.cfg().getProperty("cmc.mysqlService.username"), Solon.cfg().getProperty("cmc.mysqlService.password"));
130 140
              PreparedStatement stmt = conn.prepareStatement(sql)) {
131 141
 
132 142
             ResultSet rs = stmt.executeQuery();

+ 3
- 1
oa-back/ruoyi-agent/src/main/resources/application.yml Ver fichero

@@ -10,4 +10,6 @@ cmc:
10 10
   mysqlService:
11 11
     jdbcUrl: jdbc:mysql://localhost:3306/cmc_oa?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
12 12
     username: root
13
-    password: cmcroot
13
+    password: cmcroot
14
+server:
15
+  port: 8087

+ 1
- 1
oa-back/ruoyi-llm/pom.xml Ver fichero

@@ -73,7 +73,7 @@
73 73
         <dependency>
74 74
             <groupId>org.noear</groupId>
75 75
             <artifactId>solon-ai-mcp</artifactId>
76
-            <version>3.5.1</version>
76
+            <version>3.9.5</version>
77 77
         </dependency>
78 78
 
79 79
     </dependencies>

+ 6
- 6
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/McpController.java Ver fichero

@@ -43,10 +43,10 @@ public class McpController extends BaseController
43 43
     @Autowired
44 44
     private ICmcAgentService cmcAgentService;
45 45
 
46
-    @Value("${llmService.url}")
46
+    @Value("${cmc.llmService.url}")
47 47
     private String llmServiceUrl;
48 48
 
49
-    @Value("${milvusService.url}")
49
+    @Value("${cmc.milvusService.url}")
50 50
     private String milvusServiceUrl;
51 51
 
52 52
     /**
@@ -56,12 +56,12 @@ public class McpController extends BaseController
56 56
     @GetMapping("/answer")
57 57
     public AssistantMessage answer(String topicId, String question) throws IOException {
58 58
         McpClientProvider clientProvider = McpClientProvider.builder()
59
-                .channel(McpChannel.SSE)
60
-                .url("http://localhost:8080/mcp/sse")
59
+                .channel(McpChannel.STREAMABLE_STATELESS )
60
+                .url("http://localhost:8087/mcp/sse")
61 61
                 .build();
62 62
         ChatModel chatModel = ChatModel.of(llmServiceUrl)
63 63
                 .model("Qwen")
64
-                .defaultToolsAdd(clientProvider)
64
+                .defaultToolAdd(clientProvider)
65 65
                 .build();
66 66
 
67 67
         List<ChatMessage> messages = new ArrayList<>();
@@ -88,7 +88,7 @@ public class McpController extends BaseController
88 88
                 arguments.put("agentName", agentName);
89 89
                 arguments.put("title", question);
90 90
             }
91
-            resultContent = clientProvider.callToolAsText(name, arguments).getContent();
91
+            resultContent = clientProvider.callTool(name, arguments).getContent();
92 92
             assistantMessage = ChatMessage.ofAssistant(resultContent);
93 93
         }
94 94
         else

+ 9
- 126
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/controller/SessionController.java Ver fichero

@@ -1,33 +1,15 @@
1 1
 package com.ruoyi.web.llm.controller;
2 2
 
3
-import com.alibaba.fastjson2.JSONObject;
4
-import com.ruoyi.common.config.RuoYiConfig;
5 3
 import com.ruoyi.common.core.controller.BaseController;
6
-import com.ruoyi.llm.domain.CmcChat;
7
-import com.ruoyi.llm.service.ICmcChatService;
8
-import com.ruoyi.web.llm.service.ILangChainMilvusService;
9
-import org.noear.solon.ai.chat.ChatModel;
10
-import org.noear.solon.ai.chat.ChatResponse;
11
-import org.noear.solon.ai.chat.ChatSession;
4
+import com.ruoyi.web.llm.service.ISessionService;
12 5
 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.reactivestreams.Publisher;
6
+import org.noear.solon.core.util.MimeType;
18 7
 import org.springframework.beans.factory.annotation.Autowired;
19
-import org.springframework.beans.factory.annotation.Value;
20
-import org.springframework.http.MediaType;
21 8
 import org.springframework.web.bind.annotation.GetMapping;
22 9
 import org.springframework.web.bind.annotation.RequestMapping;
23 10
 import org.springframework.web.bind.annotation.RestController;
24 11
 import reactor.core.publisher.Flux;
25 12
 
26
-import javax.annotation.PostConstruct;
27
-import java.io.*;
28
-import java.util.ArrayList;
29
-import java.util.List;
30
-
31 13
 /**
32 14
  * session对话Controller
33 15
  *
@@ -39,123 +21,24 @@ import java.util.List;
39 21
 public class SessionController extends BaseController
40 22
 {
41 23
     @Autowired
42
-    private ICmcChatService cmcChatService;
43
-
44
-    @Autowired
45
-    private ILangChainMilvusService langChainMilvusService;
46
-
47
-    @Value("${llmService.url}")
48
-    private String llmServiceUrl;
49
-
50
-    // 复用 ChatModel 实例
51
-    private ChatModel chatModel;
52
-    private McpClientProvider clientProvider;
53
-
54
-    @PostConstruct
55
-    public void init() {
56
-        // 初始化 McpClientProvider
57
-        clientProvider = McpClientProvider.builder()
58
-                .channel(McpChannel.SSE)
59
-                .url("http://localhost:8080/mcp/sse")
60
-                .build();
61
-
62
-        // 初始化 ChatModel
63
-        chatModel = ChatModel.of(llmServiceUrl)
64
-                .model("Qwen")
65
-                .defaultToolsAdd(clientProvider)
66
-                .build();
67
-    }
24
+    private ISessionService sessionService;
68 25
 
69 26
     /**
70 27
      * 生成回答
71 28
      */
72
-    @GetMapping(value = "/answer", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
73
-    public Flux<AssistantMessage> answer(String topicId, String question) throws IOException {
74
-        List<ChatMessage> messages = new ArrayList<>();
75
-        CmcChat cmcChat = new CmcChat();
76
-        cmcChat.setTopicId(topicId);
77
-        List<CmcChat> cmcChatList = cmcChatService.selectCmcChatList(cmcChat);
78
-        for (CmcChat chat : cmcChatList) {
79
-            messages.add(ChatMessage.ofUser(chat.getInput()));
80
-            messages.add(ChatMessage.ofAssistant(chat.getOutput()));
81
-        }
82
-        StringBuilder sqlContent = new StringBuilder();
83
-        File sqlStructure = new File(RuoYiConfig.getProfile() + "/cmc_oa.sql");
84
-        try (BufferedReader br = new BufferedReader(new FileReader(sqlStructure))) {
85
-            String line;
86
-
87
-            while ((line = br.readLine()) != null) {
88
-                sqlContent.append(line).append("\n");
89
-            }
90
-        } catch (IOException e) {
91
-            System.err.println("读取文件时出错: " + e.getMessage());
92
-        }
93
-
94
-        String prompt = "你是一个MySQL专家。根据以下OA系统表结构信息:  \n" +
95
-                sqlContent + " \n" +
96
-                "用户查询:  \n"+
97
-                question  + " \n"+
98
-                "你可以使用SQLQuery工具来查询OA系统数据。  \n" +
99
-                "SQLQuery工具功能:执行SQL查询语句,获取OA系统数据  \n" +
100
-                "工具参数:sqlString(需要执行的SQL语句,格式为[SQL语句])  \n" +
101
-                "要求:  \n" +
102
-                "1. 根据用户查询需求,决定是否需要查询数据  \n" +
103
-                "2. 如果需要查询数据,调用SQLQuery工具并传入正确的SQL语句  \n" +
104
-                "3. 生成标准MYSQL查询语句,根据语义和字段类型使用COUNT/SUM/AVG等聚合函数  \n" +
105
-                "4. 如果单表字段不满足查询需求,根据OA系统表结构信息进行多张表连接查询  \n" +
106
-                "5. 如果字段值由逗号隔开,根据OA系统表结构信息进行多张表连接查询,采用concat方式多值匹配  \n" +
107
-                "6. 给生成的字段取一个简短的中文名称  \n" +
108
-                "7. 输出格式:使用[]包含sql文本,例如:[select 1 from dual]\n";
109
-        messages.add(ChatMessage.ofUser(prompt));
110
-        ChatSession chatSession =  InMemoryChatSession.builder().messages(messages).build();
111
-
112
-        Publisher<ChatResponse> responsePublisher = chatModel.prompt(chatSession).stream();
113
-
114
-        // 收集完整的响应内容,用于判断是否需要工具调用
115
-        StringBuilder resultBuilder = new StringBuilder();
116
-
117
-        // 先创建一个处理器,用于处理流式响应
118
-        Flux<AssistantMessage> flux = Flux.from(responsePublisher)
119
-                .map(chatResponse -> chatResponse.lastChoice().getMessage())
120
-                .doOnNext(assistantMessage -> {
121
-                    String content = assistantMessage.getResultContent();
122
-                    resultBuilder.append(content);
123
-                });
124
-
125
-        // 检查是否需要工具调用(异步处理)
126
-        return flux.thenMany(Flux.defer(() -> {
127
-            String resultContent = resultBuilder.toString();
128
-
129
-            if (resultContent.contains("<tool_call>")) {
130
-                // 需要工具调用,解析工具调用内容
131
-                resultContent = resultContent.split("<tool_call>\n")[1];
132
-                String content = resultContent.replace("\n</tool_call>", "");
133
-                JSONObject jsonObject = JSONObject.parseObject(content);
134
-                String name = jsonObject.getString("name");
135
-                JSONObject arguments = jsonObject.getJSONObject("arguments");
136
-
137
-                // 调用工具
138
-                resultContent = clientProvider.callToolAsText(name, arguments).getContent();
139
-                resultContent = "根据查询结果:\n" +
140
-                        resultContent + "回答问题:\n" +
141
-                        question + " \n";
142
-
143
-                // 再次调用LLM生成最终回答
144
-                return langChainMilvusService.generateAnswer(topicId, resultContent);
145
-            } else {
146
-                // 不需要工具调用,已经通过上面的flux返回了流式内容
147
-                return Flux.just(ChatMessage.ofAssistant(resultContent));
148
-            }
149
-        }));
29
+    @GetMapping(value = "/answer", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
30
+    public Flux<String> answer(String topicId, String question) {
31
+        return sessionService.answer(topicId, question);
150 32
     }
151 33
 
152 34
     /**
153 35
      * 调用LLM+RAG(外部文件)生成回答
154 36
      */
155
-    @GetMapping(value = "/answerWithDocument", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
37
+    @GetMapping(value = "/answerWithDocument", produces = MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
156 38
     public Flux<AssistantMessage> answerWithDocument(String topicId, String chatId, String question) throws Exception
157 39
     {
158
-        return langChainMilvusService.generateAnswerWithDocument(topicId, chatId, question);
40
+        return sessionService.answerWithDocument(topicId, chatId, question);
159 41
     }
160 42
 
161 43
 }
44
+

+ 18
- 0
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/ISessionService.java Ver fichero

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

+ 21
- 6
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/LangChainMilvusServiceImpl.java Ver fichero

@@ -51,6 +51,7 @@ import org.springframework.web.multipart.MultipartFile;
51 51
 import reactor.core.publisher.Flux;
52 52
 
53 53
 import javax.annotation.PostConstruct;
54
+import javax.annotation.PreDestroy;
54 55
 import java.io.*;
55 56
 import java.util.*;
56 57
 
@@ -67,10 +68,10 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
67 68
 
68 69
     private String processValue = "";
69 70
 
70
-    @Value("${llmService.url}")
71
+    @Value("${cmc.llmService.url}")
71 72
     private String llmServiceUrl;
72 73
 
73
-    @Value("${milvusService.url}")
74
+    @Value("${cmc.milvusService.url}")
74 75
     private String milvusServiceUrl;
75 76
 
76 77
     private MilvusClientV2 milvusClient;
@@ -83,10 +84,10 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
83 84
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
84 85
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
85 86
         }
86
-        milvusClient = new MilvusClientV2(
87
-                ConnectConfig.builder()
88
-                        .uri(milvusServiceUrl)
89
-                        .build());
87
+//        milvusClient = new MilvusClientV2(
88
+//                ConnectConfig.builder()
89
+//                        .uri(milvusServiceUrl)
90
+//                        .build());
90 91
     }
91 92
     
92 93
     @PostConstruct
@@ -96,6 +97,20 @@ public class LangChainMilvusServiceImpl implements ILangChainMilvusService
96 97
                 .model("Qwen")
97 98
                 .build();
98 99
     }
100
+    
101
+    @PreDestroy
102
+    public void destroyMilvusClient() {
103
+        if (milvusClient != null) {
104
+            try {
105
+                // 关闭 Milvus 客户端,释放 gRPC 通道
106
+                milvusClient.close();
107
+                System.out.println("Milvus client closed successfully");
108
+            } catch (Exception e) {
109
+                System.err.println("Error closing Milvus client: " + e.getMessage());
110
+                e.printStackTrace();
111
+            }
112
+        }
113
+    }
99 114
 
100 115
     /**
101 116
      * 上传多文件

+ 20
- 5
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/MilvusServiceImpl.java Ver fichero

@@ -17,6 +17,7 @@ import org.springframework.beans.factory.annotation.Value;
17 17
 import org.springframework.stereotype.Service;
18 18
 
19 19
 import javax.annotation.PostConstruct;
20
+import javax.annotation.PreDestroy;
20 21
 import java.text.SimpleDateFormat;
21 22
 import java.util.*;
22 23
 import java.util.stream.Collectors;
@@ -24,7 +25,7 @@ import java.util.stream.Collectors;
24 25
 @Service
25 26
 public class MilvusServiceImpl implements IMilvusService {
26 27
 
27
-    @Value("${milvusService.url}")
28
+    @Value("${cmc.milvusService.url}")
28 29
     private String milvusServiceUrl;
29 30
 
30 31
     private MilvusClientV2 milvusClient;
@@ -34,10 +35,24 @@ public class MilvusServiceImpl implements IMilvusService {
34 35
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
35 36
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
36 37
         }
37
-        milvusClient = new MilvusClientV2(
38
-                ConnectConfig.builder()
39
-                        .uri(milvusServiceUrl)
40
-                        .build());
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
+        }
41 56
     }
42 57
 
43 58
     /**

+ 269
- 0
oa-back/ruoyi-llm/src/main/java/com/ruoyi/web/llm/service/impl/SessionServiceImpl.java Ver fichero

@@ -0,0 +1,269 @@
1
+package com.ruoyi.web.llm.service.impl;
2
+
3
+import com.alibaba.fastjson2.JSONObject;
4
+import com.ruoyi.common.config.RuoYiConfig;
5
+import com.ruoyi.llm.domain.CmcChat;
6
+import com.ruoyi.llm.service.ICmcChatService;
7
+import com.ruoyi.web.llm.service.ILangChainMilvusService;
8
+import com.ruoyi.web.llm.service.ISessionService;
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.prompt.Prompt;
15
+import org.noear.solon.ai.chat.session.InMemoryChatSession;
16
+import org.noear.solon.ai.mcp.McpChannel;
17
+import org.noear.solon.ai.mcp.client.McpClientProvider;
18
+import org.springframework.beans.factory.annotation.Autowired;
19
+import org.springframework.beans.factory.annotation.Value;
20
+import org.springframework.stereotype.Service;
21
+import reactor.core.publisher.Flux;
22
+
23
+import java.io.BufferedReader;
24
+import java.io.File;
25
+import java.io.FileReader;
26
+import java.io.IOException;
27
+import java.util.ArrayList;
28
+import java.util.List;
29
+
30
+@Service
31
+public class SessionServiceImpl implements ISessionService {
32
+
33
+    @Autowired
34
+    private ICmcChatService cmcChatService;
35
+
36
+    @Autowired
37
+    private ILangChainMilvusService langChainMilvusService;
38
+
39
+    @Value("${cmc.llmService.url}")
40
+    private String llmServiceUrl;
41
+
42
+    @Override
43
+    public Flux<String> answer(String topicId, String question) {
44
+        McpClientProvider clientProvider = McpClientProvider.builder()
45
+                .channel(McpChannel.STREAMABLE_STATELESS )
46
+                .url("http://localhost:8087/mcp/sse")
47
+                .build();
48
+        ChatModel chatModel = ChatModel.of(llmServiceUrl)
49
+                .model("Qwen")
50
+                .defaultToolAdd(clientProvider)
51
+                .build();
52
+
53
+        List<ChatMessage> messages = new ArrayList<>();
54
+        CmcChat cmcChat = new CmcChat();
55
+        cmcChat.setTopicId(topicId);
56
+        List<CmcChat> cmcChatList = cmcChatService.selectCmcChatList(cmcChat);
57
+        for (CmcChat chat : cmcChatList) {
58
+            messages.add(ChatMessage.ofUser(chat.getInput()));
59
+            messages.add(ChatMessage.ofAssistant(chat.getOutput()));
60
+        }
61
+
62
+        // 读取SQL文件
63
+        StringBuilder sqlContent = new StringBuilder();
64
+        File sqlStructure = new File(RuoYiConfig.getProfile() + "/cmc_oa.sql");
65
+        try (BufferedReader br = new BufferedReader(new FileReader(sqlStructure))) {
66
+            String line;
67
+            while ((line = br.readLine()) != null) {
68
+                sqlContent.append(line).append("\n");
69
+            }
70
+        } catch (IOException e) {
71
+            System.out.println("读取SQL文件失败");
72
+        }
73
+
74
+        // 优化prompt:明确要求不输出任何中间标记
75
+        String prompt = "你是一个MySQL专家。根据以下OA系统表结构信息:  \n" +
76
+                sqlContent + " \n" +
77
+                "用户查询:  \n"+
78
+                question  + " \n"+
79
+                "你可以使用SQLQuery工具来查询OA系统数据。  \n" +
80
+                "SQLQuery工具功能:执行SQL查询语句,获取OA系统数据  \n" +
81
+                "工具参数:sqlString(需要执行的SQL语句,格式为[SQL语句])  \n" +
82
+                "要求:  \n" +
83
+                "1. 根据用户查询需求,决定是否需要查询数据  \n" +
84
+                "2. 如果需要查询数据,调用SQLQuery工具并传入正确的SQL语句  \n" +
85
+                "3. 生成标准MYSQL查询语句,根据语义和字段类型使用COUNT/SUM/AVG等聚合函数  \n" +
86
+                "4. 如果单表字段不满足查询需求,根据OA系统表结构信息进行多张表连接查询  \n" +
87
+                "5. 如果字段值由逗号隔开,根据OA系统表结构信息进行多张表连接查询,采用concat方式多值匹配  \n" +
88
+                "6. 给生成的字段取一个简短的中文名称  \n" +
89
+                "7. **重要:输出格式要求**  \n" +
90
+                "   - 不要输出任何思考过程  \n" +
91
+                "   - 所有输出必须是完整的句子  \n" +
92
+                "   - 不要输出'查询结果'、'正在生成回答'等中间提示  \n" +
93
+                "   - 直接输出最终回答内容  \n" +
94
+                "   - 如果使用工具,工具调用和结果处理对用户透明,用户只看到最终答案  \n";
95
+
96
+        messages.add(ChatMessage.ofUser(prompt));
97
+        ChatSession chatSession = InMemoryChatSession.builder().messages(messages).build();
98
+        Prompt wholePrompt = Prompt.of(prompt).attrPut("session", chatSession);
99
+
100
+        Flux<ChatResponse> chatResponse = chatModel.prompt(wholePrompt).stream();
101
+
102
+        // 使用StringBuilder缓存完整响应,用于检测工具调用
103
+        StringBuilder fullResponseBuilder = new StringBuilder();
104
+        // 标记是否已经处理过工具调用
105
+        boolean[] toolCallProcessed = {false};
106
+        // 输出过滤状态:用于抑制 tool_call 区块(对用户不可见)
107
+        StreamFilterState filterState = new StreamFilterState();
108
+
109
+        Flux<String> contentFlux = chatResponse
110
+                .concatMap(resp -> {
111
+                    String content = resp.getContent();
112
+
113
+                    // 先累积原始内容,用于 tool_call 检测
114
+                    fullResponseBuilder.append(content);
115
+                    String currentFullContent = fullResponseBuilder.toString();
116
+
117
+                    // 1) 优先处理工具调用:检测到闭合的 <tool_call>...</tool_call> 后,静默调用工具并流式输出最终答案
118
+                    if (!toolCallProcessed[0]
119
+                            && currentFullContent.contains("<tool_call>")
120
+                            && currentFullContent.contains("</tool_call>")) {
121
+                        toolCallProcessed[0] = true;
122
+                        // 进入“最终回答阶段”前重置过滤状态
123
+                        filterState.reset();
124
+                        try {
125
+                            // 提取工具调用内容
126
+                            int start = currentFullContent.indexOf("<tool_call>");
127
+                            int end = currentFullContent.indexOf("</tool_call>");
128
+                            if (start < 0 || end < 0 || end <= start) {
129
+                                throw new IllegalStateException("未找到完整的 <tool_call>...</tool_call> 区块");
130
+                            }
131
+                            String toolContent = currentFullContent.substring(start + "<tool_call>".length(), end).trim();
132
+                            JSONObject jsonObject = JSONObject.parseObject(toolContent);
133
+                            String name = jsonObject.getString("name");
134
+                            JSONObject arguments = jsonObject.getJSONObject("arguments");
135
+                            String toolResult = "";
136
+                            try {
137
+                                // 调用工具
138
+                                toolResult = clientProvider.callTool(name, arguments).getContent();
139
+                            } catch (Exception e) {
140
+                                return Flux.just("抱歉,系统执行查询时遇到问题,请稍后重试。");
141
+                            } finally {
142
+                                clientProvider.close();
143
+                            }
144
+
145
+                            // 生成最终回答 - 不添加任何中间提示
146
+                            String finalPrompt = "基于以下查询结果,直接回答用户问题:\n" +
147
+                                    "查询结果:\n" + toolResult + "\n\n" +
148
+                                    "用户问题:\n" + question + "\n\n" +
149
+                                    "要求:\n" +
150
+                                    "1. 直接给出最终答案,不要输出任何中间过程\n" +
151
+                                    "2. 不要输出'查询结果'、'根据查询结果'等前缀\n" +
152
+                                    "3. 用完整的中文句子回答,语言自然流畅\n" +
153
+                                    "4. 如果查询结果为空,如实说明";
154
+
155
+                            return chatModel.prompt(finalPrompt).stream()
156
+                                    .map(ChatResponse::getContent)
157
+                                    .filter(answer -> answer != null && !answer.isEmpty());
158
+
159
+                        } catch (Exception e) {
160
+                            return Flux.just("抱歉,系统执行查询时遇到问题,请稍后重试。");
161
+                        }
162
+                    }
163
+
164
+                    // 2) 对用户可见输出:统一用“容错状态机”剥离<tool_call> 区块
165
+                    //    不再使用“未闭合 tool_call 就整段屏蔽”的策略,避免吞掉真正回答。
166
+                    String visible = filterVisibleChunk(content, filterState);
167
+                    return visible.isEmpty() ? Flux.empty() : Flux.just(visible);
168
+                });
169
+
170
+        // 手工拼接 SSE 帧,绕过 ServerSentEvent 编码差异;并在末尾发送 [DONE]
171
+        Flux<String> sseFlux = contentFlux
172
+                .map(this::toSseDataFrame)
173
+                .concatWith(Flux.just("data: [DONE]\n\n"));
174
+
175
+        return sseFlux;
176
+    }
177
+
178
+    @Override
179
+    public Flux<AssistantMessage> answerWithDocument(String topicId, String chatId, String question) throws Exception {
180
+        return langChainMilvusService.generateAnswerWithDocument(topicId, chatId, question);
181
+    }
182
+
183
+    private String toSseDataFrame(String data) {
184
+        if (data == null) {
185
+            return "data: \n\n";
186
+        }
187
+        // SSE 要求每一行都以 data: 开头
188
+        String normalized = data.replace("\r\n", "\n");
189
+        StringBuilder sb = new StringBuilder(normalized.length() + 16);
190
+        sb.append("data: ");
191
+        int start = 0;
192
+        while (true) {
193
+            int idx = normalized.indexOf('\n', start);
194
+            if (idx < 0) {
195
+                sb.append(normalized.substring(start));
196
+                break;
197
+            }
198
+            sb.append(normalized, start, idx);
199
+            sb.append("\n");
200
+            sb.append("data: ");
201
+            start = idx + 1;
202
+        }
203
+        sb.append("\n\n");
204
+        return sb.toString();
205
+    }
206
+
207
+    private static final class StreamFilterState {
208
+        boolean inToolCall = false;
209
+
210
+        void reset() {
211
+            inToolCall = false;
212
+        }
213
+    }
214
+
215
+    /**
216
+     * 对用户可见输出过滤:
217
+     * - 丢弃 <tool_call>...</tool_call> 内容(容错:缺失 </tool_call> 时,遇到空行分隔则自动退出 tool_call)
218
+     * 说明:工具调用检测/执行仍基于原始累积文本,不依赖该过滤器。
219
+     */
220
+    private String filterVisibleChunk(String chunk, StreamFilterState st) {
221
+        if (chunk == null || chunk.isEmpty()) {
222
+            return "";
223
+        }
224
+        String s = chunk.replace("\r\n", "\n");
225
+        StringBuilder out = new StringBuilder(s.length());
226
+        int i = 0;
227
+        while (i < s.length()) {
228
+            // 在 tool_call 内:优先寻找 </tool_call>;找不到则容错以空行分隔退出
229
+            if (st.inToolCall) {
230
+                int end = s.indexOf("</tool_call>", i);
231
+                if (end >= 0) {
232
+                    st.inToolCall = false;
233
+                    i = end + "</tool_call>".length();
234
+                    continue;
235
+                }
236
+                int sep = s.indexOf("\n\n", i);
237
+                if (sep >= 0) {
238
+                    st.inToolCall = false;
239
+                    i = sep + 2;
240
+                    continue;
241
+                }
242
+                // 剩余都认为在 tool_call 内
243
+                break;
244
+            }
245
+
246
+            // 不在任何区块内:找下一个起始标签
247
+            int nextTool = s.indexOf("<tool_call>", i);
248
+            int next;
249
+            if (nextTool == -1) {
250
+                out.append(s.substring(i));
251
+                break;
252
+            } else {
253
+                next = nextTool;
254
+            }
255
+
256
+            if (next > i) {
257
+                out.append(s, i, next);
258
+            }
259
+            st.inToolCall = true;
260
+            i = next + "<tool_call>".length();
261
+        }
262
+
263
+        // 兜底移除残留标签字面量(避免显示到前端)
264
+        return out.toString()
265
+                .replace("<tool_call>", "")
266
+                .replace("</tool_call>", "");
267
+    }
268
+
269
+}

+ 1
- 1
oa-back/ruoyi-system/pom.xml Ver fichero

@@ -60,7 +60,7 @@
60 60
         <dependency>
61 61
             <groupId>org.noear</groupId>
62 62
             <artifactId>solon-ai</artifactId>
63
-            <version>3.5.1</version>
63
+            <version>3.9.5</version>
64 64
         </dependency>
65 65
 
66 66
         <dependency>

+ 21
- 6
oa-back/ruoyi-system/src/main/java/com/ruoyi/llm/service/impl/CmcAgentServiceImpl.java Ver fichero

@@ -48,6 +48,7 @@ import org.springframework.stereotype.Service;
48 48
 import org.springframework.web.multipart.MultipartFile;
49 49
 
50 50
 import javax.annotation.PostConstruct;
51
+import javax.annotation.PreDestroy;
51 52
 import java.io.*;
52 53
 import java.nio.file.Files;
53 54
 import java.nio.file.Path;
@@ -77,10 +78,10 @@ public class CmcAgentServiceImpl implements ICmcAgentService
77 78
 
78 79
     private static final EmbeddingModel embeddingModel = new BgeSmallZhV15EmbeddingModel();
79 80
 
80
-    @Value("${llmService.url}")
81
+    @Value("${cmc.llmService.url}")
81 82
     private String llmServiceUrl;
82 83
 
83
-    @Value("${milvusService.url}")
84
+    @Value("${cmc.milvusService.url}")
84 85
     private String milvusServiceUrl;
85 86
 
86 87
     private MilvusClientV2 milvusClient;
@@ -90,10 +91,24 @@ public class CmcAgentServiceImpl implements ICmcAgentService
90 91
         if (milvusServiceUrl == null || milvusServiceUrl.isEmpty()) {
91 92
             throw new IllegalArgumentException("milvusServiceUrl 配置不能为空");
92 93
         }
93
-        milvusClient = new MilvusClientV2(
94
-                ConnectConfig.builder()
95
-                        .uri(milvusServiceUrl)
96
-                        .build());
94
+//        milvusClient = new MilvusClientV2(
95
+//                ConnectConfig.builder()
96
+//                        .uri(milvusServiceUrl)
97
+//                        .build());
98
+    }
99
+    
100
+    @PreDestroy
101
+    public void destroyMilvusClient() {
102
+        if (milvusClient != null) {
103
+            try {
104
+                // 关闭 Milvus 客户端,释放 gRPC 通道
105
+                milvusClient.close();
106
+                System.out.println("Milvus client closed successfully");
107
+            } catch (Exception e) {
108
+                System.err.println("Error closing Milvus client: " + e.getMessage());
109
+                e.printStackTrace();
110
+            }
111
+        }
97 112
     }
98 113
 
99 114
     /**

+ 15
- 0
oa-back/ruoyi-system/src/main/java/com/ruoyi/oa/domain/CmcTrainApproval.java Ver fichero

@@ -39,6 +39,11 @@ public class CmcTrainApproval extends BaseEntity
39 39
     @Excel(name = "上报日期", width = 30, dateFormat = "yyyy-MM-dd")
40 40
     private Date reportTime;
41 41
 
42
+    /** 参培日期 */
43
+    @JsonFormat(pattern = "yyyy-MM-dd")
44
+    @Excel(name = "参培日期", width = 30, dateFormat = "yyyy-MM-dd")
45
+    private Date trainTime;
46
+
42 47
     /** 培训名称 */
43 48
     @Excel(name = "培训名称")
44 49
     private String trainName;
@@ -105,6 +110,15 @@ public class CmcTrainApproval extends BaseEntity
105 110
     {
106 111
         return reportTime;
107 112
     }
113
+    public void setTrainTime(Date trainTime)
114
+    {
115
+        this.trainTime = trainTime;
116
+    }
117
+
118
+    public Date getTrainTime()
119
+    {
120
+        return trainTime;
121
+    }
108 122
     public void setTrainName(String trainName) 
109 123
     {
110 124
         this.trainName = trainName;
@@ -199,6 +213,7 @@ public class CmcTrainApproval extends BaseEntity
199 213
             .append("participateId", getParticipateId())
200 214
             .append("userId", getUserId())
201 215
             .append("reportTime", getReportTime())
216
+            .append("trainTime", getTrainTime())
202 217
             .append("trainName", getTrainName())
203 218
             .append("content", getContent())
204 219
             .append("hours", getHours())

+ 1
- 0
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcCarMapper.xml Ver fichero

@@ -50,6 +50,7 @@
50 50
             <if test="remark != null  and remark != ''"> and c.remark = #{remark}</if>
51 51
             <if test="status != null  and status != ''"> and c.status = #{status}</if>
52 52
         </where>
53
+        order by c.is_rent, c.car_id
53 54
     </select>
54 55
 
55 56
     <select id="selectCmcCarByCarId" parameterType="Integer" resultMap="CmcCarResult">

+ 1
- 0
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcStudyMapper.xml Ver fichero

@@ -53,6 +53,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
53 53
             <if test="title != null "> and r.title like concat('%', #{title}, '%')</if>
54 54
             <if test="type != null "> and r.type like concat('%', #{type}, '%')</if>
55 55
         </where>
56
+        order by s.last_time desc
56 57
     </select>
57 58
 
58 59
     <select id="selectCmcStudyStatistic" parameterType="CmcStudy" resultMap="CmcStudyResult">

+ 7
- 1
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcTrainApprovalMapper.xml Ver fichero

@@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
9 9
         <result property="userId"    column="user_id"    />
10 10
         <result property="deptId"    column="dept_id"    />
11 11
         <result property="reportTime"    column="report_time"    />
12
+        <result property="trainTime"    column="train_time"    />
12 13
         <result property="trainName"    column="train_name"    />
13 14
         <result property="content"    column="content"    />
14 15
         <result property="document"    column="document"    />
@@ -37,7 +38,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
37 38
     </resultMap>
38 39
 
39 40
     <sql id="selectCmcTrainApprovalVo">
40
-        select ta.participate_id, ta.user_id, u.nick_name as report_nick_name, ta.dept_id, d.dept_name, ta.report_time, ta.train_name, ta.content,ta.document, ta.hours, ta.dept_user_id, u1.nick_name as dept_nick_name, ta.dept_time, ta.dept_comment from cmc_train_approval as ta
41
+        select ta.participate_id, ta.user_id, u.nick_name as report_nick_name, ta.dept_id, d.dept_name, ta.report_time, ta.train_time, ta.train_name, ta.content,ta.document, ta.hours, ta.dept_user_id, u1.nick_name as dept_nick_name, ta.dept_time, ta.dept_comment from cmc_train_approval as ta
41 42
         left join sys_user as u on u.user_id = ta.user_id
42 43
         left join sys_user as u1 on u1.user_id = ta.dept_user_id
43 44
         left join sys_dept as d on d.dept_id = ta.dept_id
@@ -49,6 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
49 50
             <if test="userId != null "> and ta.user_id = #{userId}</if>
50 51
             <if test="deptId != null "> and ta.dept_id = #{deptId}</if>
51 52
             <if test="reportTime != null "> and YEAR(ta.report_time) = YEAR(#{reportTime})</if>
53
+            <if test="reportTime != null "> and YEAR(ta.train_time) = YEAR(#{trainTime})</if>
52 54
             <if test="trainName != null  and trainName != ''"> and ta.train_name like concat('%', #{trainName}, '%')</if>
53 55
             <if test="content != null  and content != ''"> and ta.content = #{content}</if>
54 56
             <if test="document != null  and document != ''"> and ta.document = #{document}</if>
@@ -57,6 +59,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
57 59
             <if test="deptTime != null "> and ta.dept_time = #{deptTime}</if>
58 60
             <if test="deptComment != null  and deptComment != ''"> and ta.dept_comment = #{deptComment}</if>
59 61
         </where>
62
+        order by ta.report_time desc
60 63
     </select>
61 64
     
62 65
     <select id="selectCmcTrainApprovalByParticipateId" parameterType="String" resultMap="CmcTrainApprovalResult">
@@ -71,6 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
71 74
             <if test="userId != null">user_id,</if>
72 75
             <if test="deptId != null">dept_id,</if>
73 76
             <if test="reportTime != null">report_time,</if>
77
+            <if test="trainTime != null">train_time,</if>
74 78
             <if test="trainName != null">train_name,</if>
75 79
             <if test="content != null">content,</if>
76 80
             <if test="document != null">document,</if>
@@ -84,6 +88,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
84 88
             <if test="userId != null">#{userId},</if>
85 89
             <if test="deptId != null">#{deptId},</if>
86 90
             <if test="reportTime != null">#{reportTime},</if>
91
+            <if test="trainTime != null">#{trainTime},</if>
87 92
             <if test="trainName != null">#{trainName},</if>
88 93
             <if test="content != null">#{content},</if>
89 94
             <if test="document != null">#{document},</if>
@@ -100,6 +105,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
100 105
             <if test="userId != null">user_id = #{userId},</if>
101 106
             <if test="deptId != null">dept_id = #{deptId},</if>
102 107
             <if test="reportTime != null">report_time = #{reportTime},</if>
108
+            <if test="trainTime != null">train_time = #{trainTime},</if>
103 109
             <if test="trainName != null">train_name = #{trainName},</if>
104 110
             <if test="content != null">content = #{content},</if>
105 111
             <if test="document != null">document = #{document},</if>

+ 116
- 232
oa-ui/src/api/llm/session.js Ver fichero

@@ -1,8 +1,8 @@
1 1
 /*
2 2
  * @Author: wrh
3 3
  * @Date: 2025-04-08 14:23:04
4
- * @LastEditors: Please set LastEditors
5
- * @LastEditTime: 2025-07-22 15:42:04
4
+ * @LastEditors: wrh
5
+ * @LastEditTime: 2026-03-11 10:29:17
6 6
  */
7 7
 import request from '@/utils/request'
8 8
 import { getToken } from '@/utils/auth'
@@ -25,258 +25,142 @@ export function getAnswerWithDocument(question) {
25 25
   })
26 26
 }
27 27
 
28
-// 流式回答API - 使用fetch API处理流式响应
29
-export function getAnswerStream(params, onMessage, onError, onComplete) {
30
-  const baseURL = process.env.VUE_APP_BASE_API
31
-  const url = `${baseURL}/llm/session/answer?topicId=${params.topicId}&question=${encodeURIComponent(params.question)}`
32
-
33
-  const controller = new AbortController()
34
-
35
-  fetch(url, {
36
-    method: 'GET',
37
-    headers: {
38
-      'Authorization': 'Bearer ' + getToken(),
39
-      'Accept': 'application/json, text/event-stream',
40
-      'Cache-Control': 'no-cache'
41
-    },
42
-    signal: controller.signal
43
-  }).then(response => {
44
-    if (!response.ok) {
45
-      throw new Error(`HTTP error! status: ${response.status}`)
28
+function parseSseEvents(buffer) {
29
+  // SSE 事件以空行分隔:\n\n(兼容 \r\n)
30
+  const events = []
31
+  const normalized = buffer.replace(/\r\n/g, '\n')
32
+  const parts = normalized.split('\n\n')
33
+  // 最后一段可能是不完整事件,留给下次拼接
34
+  const rest = parts.pop() ?? ''
35
+
36
+  for (const part of parts) {
37
+    if (!part.trim()) continue
38
+    const lines = part.split('\n')
39
+    const dataLines = []
40
+    for (const line of lines) {
41
+      if (line.startsWith('data:')) {
42
+        dataLines.push(line.slice(5).trimStart())
43
+      }
46 44
     }
45
+    const data = dataLines.join('\n')
46
+    if (data !== '') events.push(data)
47
+  }
47 48
 
48
-    const reader = response.body.getReader()
49
-    const decoder = new TextDecoder()
50
-    let buffer = ''
51
-
52
-    function readStream() {
53
-      return reader.read().then(({ done, value }) => {
54
-        if (done) {
55
-          if (buffer.trim()) {
56
-            const lines = buffer.split(/\r?\n/)
57
-            lines.forEach(line => {
58
-              line = line.trim()
59
-              if (!line || line.startsWith(':')) return
60
-
61
-              let jsonData = null
62
-              if (line.startsWith('data: ')) {
63
-                try {
64
-                  jsonData = JSON.parse(line.slice(6))
65
-                } catch (error) {
66
-                  console.error('解析剩余SSE数据失败:', error, line)
67
-                }
68
-              } else if (line.startsWith('data:')) {
69
-                try {
70
-                  jsonData = JSON.parse(line.slice(5))
71
-                } catch (error) {
72
-                  console.error('解析剩余SSE数据失败(无空格):', error, line)
73
-                }
74
-              } else {
75
-                try {
76
-                  jsonData = JSON.parse(line)
77
-                } catch (error) {
78
-                  console.error('解析剩余JSON数据失败:', error, line)
79
-                }
80
-              }
81
-
82
-              if (jsonData) {
83
-                if (jsonData.resultContent) {
84
-                  onMessage(jsonData.resultContent)
85
-                } else if (jsonData.choices && jsonData.choices[0] && jsonData.choices[0].delta && jsonData.choices[0].delta.content) {
86
-                  onMessage(jsonData.choices[0].delta.content)
87
-                } else if (typeof jsonData === 'string') {
88
-                  onMessage(jsonData)
89
-                }
90
-              }
91
-            })
92
-          }
93
-
94
-          onComplete()
95
-          return
96
-        }
97
-
98
-        const chunk = decoder.decode(value, { stream: true })
99
-        buffer += chunk
100
-
101
-        const lines = buffer.split(/\r?\n/)
102
-        buffer = lines.pop() || ''
103
-
104
-        lines.forEach(line => {
105
-          line = line.trim()
106
-          if (!line || line.startsWith(':')) return
107
-
108
-          let jsonData = null
109
-          if (line.startsWith('data: ')) {
110
-            try {
111
-              jsonData = JSON.parse(line.slice(6))
112
-            } catch (error) {
113
-              console.error('解析SSE数据失败:', error, line)
114
-            }
115
-          } else if (line.startsWith('data:')) {
116
-            try {
117
-              jsonData = JSON.parse(line.slice(5))
118
-            } catch (error) {
119
-              console.error('解析SSE数据失败(无空格):', error, line)
120
-            }
121
-          } else {
122
-            try {
123
-              jsonData = JSON.parse(line)
124
-            } catch (error) {
125
-              console.error('解析JSON数据失败:', error, line)
126
-            }
127
-          }
128
-
129
-          if (jsonData) {
130
-            if (jsonData.resultContent) {
131
-              onMessage(jsonData.resultContent)
132
-            } else if (typeof jsonData === 'string') {
133
-              onMessage(jsonData)
134
-            }
135
-          }
136
-        })
49
+  return { events, rest }
50
+}
137 51
 
138
-        return readStream()
139
-      })
140
-    }
52
+function normalizeSseData(data) {
53
+  if (data == null) return ''
54
+  let s = String(data)
55
+  // 有些后端会把 payload 自己也带上 "data:",这里做兼容剥离
56
+  // 例如:data:data: xxx  ->  xxx
57
+  while (s.startsWith('data:')) {
58
+    s = s.slice(5)
59
+    if (s.startsWith(' ')) s = s.slice(1)
60
+  }
61
+  return s
62
+}
141 63
 
142
-    return readStream()
143
-  })
144
-    .catch(error => {
145
-      if (error.name === 'AbortError') {
146
-        console.log('请求被取消')
147
-        return
64
+function stripToolCallStream(text, state) {
65
+  if (!text) return ''
66
+  const s = String(text)
67
+  let i = 0
68
+  let out = ''
69
+  while (i < s.length) {
70
+    if (!state.inToolCall) {
71
+      const start = s.indexOf('<tool_call>', i)
72
+      if (start === -1) {
73
+        out += s.slice(i)
74
+        break
148 75
       }
149
-      console.error('流式请求错误:', error)
150
-      onError(new Error('网络连接失败,请检查网络连接后重试'))
151
-    })
152
-
153
-  return controller
76
+      out += s.slice(i, start)
77
+      state.inToolCall = true
78
+      i = start + '<tool_call>'.length
79
+    } else {
80
+      const end = s.indexOf('</tool_call>', i)
81
+      if (end === -1) {
82
+        // 剩余都在 tool_call 内,丢弃
83
+        return out
84
+      }
85
+      state.inToolCall = false
86
+      i = end + '</tool_call>'.length
87
+    }
88
+  }
89
+  return out
154 90
 }
155 91
 
156
-// 流式回答API(带文档)- 使用fetch API处理流式响应
157
-export function getAnswerWithDocumentStream(params, onMessage, onError, onComplete) {
158
-  const baseURL = process.env.VUE_APP_BASE_API
159
-  const url = `${baseURL}/llm/session/answerWithDocument?topicId=${params.topicId}&chatId=${params.chatId}&question=${encodeURIComponent(params.question)}`
160
-
92
+function streamFetchSse(url, onMessage, onError, onComplete) {
161 93
   const controller = new AbortController()
162
-
94
+  const toolCallState = { inToolCall: false }
163 95
   fetch(url, {
164 96
     method: 'GET',
165 97
     headers: {
166 98
       'Authorization': 'Bearer ' + getToken(),
167
-      'Accept': 'application/json, text/event-stream',
99
+      'Accept': 'text/event-stream',
168 100
       'Cache-Control': 'no-cache'
169 101
     },
170 102
     signal: controller.signal
171
-  }).then(response => {
172
-    if (!response.ok) {
173
-      throw new Error(`HTTP error! status: ${response.status}`)
174
-    }
175
-
176
-    const reader = response.body.getReader()
177
-    const decoder = new TextDecoder()
178
-    let buffer = ''
179
-
180
-    function readStream() {
181
-      return reader.read().then(({ done, value }) => {
182
-        if (done) {
183
-          if (buffer.trim()) {
184
-            const lines = buffer.split(/\r?\n/)
185
-            lines.forEach(line => {
186
-              line = line.trim()
187
-              if (!line || line.startsWith(':')) return
188
-
189
-              let jsonData = null
190
-              if (line.startsWith('data: ')) {
191
-                try {
192
-                  jsonData = JSON.parse(line.slice(6))
193
-                } catch (error) {
194
-                  console.error('解析剩余SSE数据失败:', error, line)
195
-                }
196
-              } else if (line.startsWith('data:')) {
197
-                try {
198
-                  jsonData = JSON.parse(line.slice(5))
199
-                } catch (error) {
200
-                  console.error('解析剩余SSE数据失败(无空格):', error, line)
201
-                }
202
-              } else {
203
-                try {
204
-                  jsonData = JSON.parse(line)
205
-                } catch (error) {
206
-                  console.error('解析剩余JSON数据失败:', error, line)
207
-                }
208
-              }
209
-
210
-              if (jsonData) {
211
-                if (jsonData.resultContent) {
212
-                  onMessage(jsonData.resultContent)
213
-                } else if (jsonData.choices && jsonData.choices[0] && jsonData.choices[0].delta && jsonData.choices[0].delta.content) {
214
-                  onMessage(jsonData.choices[0].delta.content)
215
-                } else if (typeof jsonData === 'string') {
216
-                  onMessage(jsonData)
217
-                }
218
-              }
219
-            })
103
+  })
104
+    .then(async (response) => {
105
+      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
106
+      if (!response.body) throw new Error('ReadableStream not supported')
107
+
108
+      const reader = response.body.getReader()
109
+      const decoder = new TextDecoder('utf-8')
110
+      let buffer = ''
111
+
112
+      while (true) {
113
+        const { done, value } = await reader.read()
114
+        if (done) break
115
+
116
+        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()
124
+            controller.abort()
125
+            return
220 126
           }
221
-
222
-          onComplete()
223
-          return
127
+          const visible = stripToolCallStream(normalized, toolCallState)
128
+          if (visible !== '') onMessage(visible)
224 129
         }
130
+      }
225 131
 
226
-        const chunk = decoder.decode(value, { stream: true })
227
-        buffer += chunk
228
-
229
-        const lines = buffer.split(/\r?\n/)
230
-        buffer = lines.pop() || ''
231
-
232
-        lines.forEach(line => {
233
-          line = line.trim()
234
-          if (!line || line.startsWith(':')) return
235
-
236
-          let jsonData = null
237
-          if (line.startsWith('data: ')) {
238
-            try {
239
-              jsonData = JSON.parse(line.slice(6))
240
-            } catch (error) {
241
-              console.error('解析SSE数据失败:', error, line)
242
-            }
243
-          } else if (line.startsWith('data:')) {
244
-            try {
245
-              jsonData = JSON.parse(line.slice(5))
246
-            } catch (error) {
247
-              console.error('解析SSE数据失败(无空格):', error, line)
248
-            }
249
-          } else {
250
-            try {
251
-              jsonData = JSON.parse(line)
252
-            } catch (error) {
253
-              console.error('解析JSON数据失败:', error, line)
254
-            }
255
-          }
256
-
257
-          if (jsonData) {
258
-            if (jsonData.resultContent) {
259
-              onMessage(jsonData.resultContent)
260
-            } else if (typeof jsonData === 'string') {
261
-              onMessage(jsonData)
262
-            }
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)
263 140
           }
264
-        })
265
-
266
-        return readStream()
267
-      })
268
-    }
269
-
270
-    return readStream()
271
-  })
272
-    .catch(error => {
273
-      if (error.name === 'AbortError') {
274
-        console.log('请求被取消')
275
-        return
141
+        }
276 142
       }
143
+      onComplete()
144
+    })
145
+    .catch((error) => {
146
+      if (error.name === 'AbortError') return
277 147
       console.error('流式请求错误:', error)
278
-      onError(new Error('网络连接失败,请检查网络连接后重试'))
148
+      onError(error)
279 149
     })
280 150
 
281 151
   return controller
152
+}
153
+
154
+// 流式回答API(SSE)
155
+export function getAnswerStream(params, onMessage, onError, onComplete) {
156
+  const baseURL = process.env.VUE_APP_BASE_API
157
+  const url = `${baseURL}/llm/session/answer?topicId=${params.topicId}&question=${encodeURIComponent(params.question)}`
158
+  return streamFetchSse(url, onMessage, onError, onComplete)
159
+}
160
+
161
+// 流式回答API(带文档)- 简化版
162
+export function getAnswerWithDocumentStream(params, onMessage, onError, onComplete) {
163
+  const baseURL = process.env.VUE_APP_BASE_API
164
+  const url = `${baseURL}/llm/session/answerWithDocument?topicId=${params.topicId}&chatId=${params.chatId}&question=${encodeURIComponent(params.question)}`
165
+  return streamFetchSse(url, onMessage, onError, onComplete)
282 166
 }

+ 14
- 14
oa-ui/src/views/flowable/form/changeForm.vue Ver fichero

@@ -7,19 +7,19 @@
7 7
             <el-button type="primary" size="mini" @click="openProject = true" v-if="taskName == '变更登记'">选择项目</el-button>
8 8
             <el-descriptions border v-if="isSelect" style="margin-top: 10px;" :column="2" direction="vertical">
9 9
               <el-descriptions-item label="项目名称" label-class-name="my-label">{{ chooseProject.projectName
10
-                }}</el-descriptions-item>
10
+              }}</el-descriptions-item>
11 11
               <el-descriptions-item label="项目编号" label-class-name="my-label">{{ chooseProject.projectNumber
12
-                }}</el-descriptions-item>
12
+              }}</el-descriptions-item>
13 13
               <el-descriptions-item label="甲方单位" label-class-name="my-label">{{ chooseProject.partyA.partyAName
14
-                }}</el-descriptions-item>
14
+              }}</el-descriptions-item>
15 15
               <el-descriptions-item label="项目类型" label-class-name="my-label">{{ chooseProject.projectType
16
-                }}</el-descriptions-item>
16
+              }}</el-descriptions-item>
17 17
               <el-descriptions-item label="承担部门" label-class-name="my-label">{{
18
-      chooseProject.undertakingDeptName
19
-    }}</el-descriptions-item>
18
+                chooseProject.undertakingDeptName
19
+              }}</el-descriptions-item>
20 20
               <el-descriptions-item label="项目负责人" label-class-name="my-label">{{ chooseProject.projectLeaderUser
21
-      ? chooseProject.projectLeaderUser.nickName : ''
22
-                }}</el-descriptions-item>=
21
+                ? chooseProject.projectLeaderUser.nickName : ''
22
+              }}</el-descriptions-item>=
23 23
             </el-descriptions>
24 24
           </el-form-item>
25 25
           <el-form-item label="变更内容" prop="content">
@@ -260,12 +260,12 @@ export default {
260 260
             const params = { taskId: this.taskForm.taskId };
261 261
             getNextFlowNode(params).then(res => {
262 262
               if (this.taskName == '变更登记') {
263
-                sendQyMessage(this.leaderList);
264 263
                 this.$set(this.taskForm.variables, "approvalList", this.leaderList);
265 264
                 complete(this.taskForm).then(response => {
266 265
                   this.$modal.msgSuccess(response.msg);
267 266
                   this.$emit('goBack')
268 267
                 });
268
+                this.sendQyMessage(this.leaderList);
269 269
               } else if (this.taskName == '变更确认') {
270 270
                 this.$modal.confirm('最后一个节点,提交将结束流程,是否提交?').then(() => {
271 271
                   complete(this.taskForm).then(response => {
@@ -284,12 +284,12 @@ export default {
284 284
             });
285 285
             const params = { taskId: this.taskForm.taskId };
286 286
             getNextFlowNode(params).then(res => {
287
-              sendQyMessage(this.leaderList);
288 287
               this.$set(this.taskForm.variables, "approvalList", this.leaderList);
289 288
               complete(this.taskForm).then(response => {
290 289
                 this.$modal.msgSuccess(response.msg);
291 290
                 this.$emit('goBack')
292 291
               });
292
+              this.sendQyMessage(this.leaderList);
293 293
             })
294 294
           }
295 295
         }
@@ -299,10 +299,10 @@ export default {
299 299
     async sendQyMessage(userIds) {
300 300
       if (userIds && userIds.length > 0) {
301 301
         let formData = new FormData();
302
-        let message = "经营发展部发起项目变更通知:  \n>" + 
303
-        "登记人:<font color='info'>" + this.getUserName(this.form.registrant) + "</font> \n>" + 
304
-        "变更项目:" + this.chooseProject.projectNumber + "-" + this.chooseProject.projectName + " \n>" + 
305
-        "变更内容:" + this.form.content + " \n>";
302
+        let message = "经营发展部发起项目变更通知:  \n>" +
303
+          "登记人:<font color='info'>" + this.getUserName(this.form.registrant) + "</font> \n>" +
304
+          "变更项目:" + this.chooseProject.projectNumber + "-" + this.chooseProject.projectName + " \n>" +
305
+          "变更内容:" + this.form.content + " \n>";
306 306
         formData.append('message', message);
307 307
         let userString = [];
308 308
         for (let u of userIds) {

+ 11
- 3
oa-ui/src/views/flowable/form/oa/studyForm.vue Ver fichero

@@ -30,6 +30,12 @@
30 30
             </el-col>
31 31
           </el-row>
32 32
 
33
+          <el-form-item label="参培日期" prop="trainTime">
34
+            <el-date-picker clearable v-model="form.trainTime" type="date" value-format="yyyy-MM-dd"
35
+              placeholder="请选择参培日期" :disabled="taskName != '参培上报'">
36
+            </el-date-picker>
37
+          </el-form-item>
38
+
33 39
           <el-form-item label="主要内容" prop="content">
34 40
             <el-input v-model="form.content" type="textarea" :rows="4" placeholder="请输入主要内容"
35 41
               :disabled="taskName != '参培上报'" />
@@ -66,7 +72,8 @@
66 72
 
67 73
           <template v-if="taskName != '参培上报'">
68 74
             <el-form-item label="部门审核意见" prop="dept_comment">
69
-              <el-input v-model="form.deptComment" type="textarea" :rows="3" placeholder="请输入部门审核意见" :disabled="taskName != '部门审核'" />
75
+              <el-input v-model="form.deptComment" type="textarea" :rows="3" placeholder="请输入部门审核意见"
76
+                :disabled="taskName != '部门审核'" />
70 77
             </el-form-item>
71 78
 
72 79
             <el-row>
@@ -140,6 +147,7 @@ export default {
140 147
         userId: '',
141 148
         deptId: '',
142 149
         reportTime: '',
150
+        trainTime: '',
143 151
         trainName: '',
144 152
         content: '',
145 153
         document: '',
@@ -156,8 +164,8 @@ export default {
156 164
         applierUser: null
157 165
       },
158 166
       rules: {
159
-        reportTime: [
160
-          { required: true, message: '请选择上报日期', trigger: 'change' }
167
+        trainTime: [
168
+          { required: true, message: '请选择参培日期', trigger: 'change' }
161 169
         ],
162 170
         trainName: [
163 171
           { required: true, message: '请输入培训名称', trigger: 'blur' }

+ 191
- 92
oa-ui/src/views/llm/chat/index.vue Ver fichero

@@ -2,7 +2,7 @@
2 2
  * @Author: ysh
3 3
  * @Date: 2025-04-07 14:14:05
4 4
  * @LastEditors: wrh
5
- * @LastEditTime: 2025-12-17 16:24:24
5
+ * @LastEditTime: 2026-03-10 22:19:16
6 6
 -->
7 7
 <template>
8 8
   <div class="app-container">
@@ -217,8 +217,8 @@
217 217
                   </div>
218 218
                 </div>
219 219
               </div>
220
-              <!-- 加载状态 -->
221
-              <div v-if="isLoading" class="message-item ai">
220
+              <!-- 加载状态:仅在尚未收到首段流内容时显示,避免出现两个AI气泡 -->
221
+              <div v-if="isLoading && !streamingStarted" class="message-item ai">
222 222
                 <div class="message-avatar">
223 223
                   <div class="ai-avatar">
224 224
                     <img :src="logoImg" alt="AI" class="ai-logo" />
@@ -227,6 +227,7 @@
227 227
                 <div class="message-content">
228 228
                   <div class="message-bubble ai">
229 229
                     <div class="loading-dots">
230
+                      <span></span><span></span><span></span>
230 231
                     </div>
231 232
                   </div>
232 233
                 </div>
@@ -296,6 +297,67 @@ import { getAnswer, getAnswerWithDocument, getAnswerStream, getAnswerWithDocumen
296 297
 import logoImg from '@/assets/images/logo.png'
297 298
 import { marked } from 'marked';
298 299
 
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
+
299 361
 export default {
300 362
   name: 'ChatView',
301 363
   data() {
@@ -321,6 +383,7 @@ export default {
321 383
       isUploading: false,
322 384
       documentChatId: '',
323 385
       messageFileMap: new Map(), // 存储消息对应的文件列表,key为chartId
386
+      streamingStarted: false,
324 387
       classifiedRecent: {
325 388
         today: [],
326 389
         yesterday: [],
@@ -512,11 +575,12 @@ export default {
512 575
         await this.createNewChat();
513 576
       }
514 577
       this.showNewChatWelcome = false;
578
+      this.streamingStarted = false;
515 579
       const userMessage = {
516 580
         userId: this.$store.state.user.id,
517 581
         input: this.inputMessage,
518 582
         topicId: this.currentTopicId,
519
-        inputTime: parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}'),
583
+        inputTime: this.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}'),
520 584
         chatId: this.documentChatId || null
521 585
       };
522 586
 
@@ -554,7 +618,7 @@ export default {
554 618
         input: '',
555 619
         output: '',
556 620
         topicId: this.currentTopicId,
557
-        outputTime: parseTime(new Date(), '{y}-{m}-{d}')
621
+        outputTime: this.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
558 622
       };
559 623
       this.chatMessages.push(aiMessage);
560 624
 
@@ -562,6 +626,15 @@ export default {
562 626
 
563 627
       this.isLoading = true;
564 628
 
629
+      const typewriter = createTypewriter((chunk) => {
630
+        aiMessage.output += chunk
631
+        aiMessage.outputTime = this.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
632
+        this.$nextTick(() => this.scrollToBottom())
633
+      }, {
634
+        intervalMs: 25,
635
+        maxCharsPerTick: 1
636
+      })
637
+
565 638
       const streamParams = {
566 639
         topicId: this.currentTopicId,
567 640
         question: userMessage.input
@@ -574,12 +647,13 @@ export default {
574 647
           streamParams,
575 648
           (content) => {
576 649
             const that = this;
577
-            let cleanContent = content.replace(/<\/?think>/g, '');
578
-            if (!cleanContent.trim()) {
650
+            if (!that.streamingStarted) that.streamingStarted = true;
651
+
652
+            if (!content || !String(content).trim()) {
579 653
               return;
580 654
             }
581
-            aiMessage.output += cleanContent;
582
-            aiMessage.outputTime = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}');
655
+
656
+            typewriter.push(String(content))
583 657
 
584 658
             if (window.responseTimeout) {
585 659
               clearTimeout(window.responseTimeout);
@@ -588,6 +662,7 @@ export default {
588 662
               if (that.isLoading) {
589 663
                 console.log('=== 响应超时强制结束 ===');
590 664
                 that.isLoading = false;
665
+                typewriter.stop()
591 666
                 if (window.responseTimeout) {
592 667
                   clearTimeout(window.responseTimeout);
593 668
                   window.responseTimeout = null;
@@ -595,9 +670,6 @@ export default {
595 670
               }
596 671
             }, 300000);
597 672
 
598
-            that.$nextTick(() => {
599
-              that.scrollToBottom();
600
-            });
601 673
           },
602 674
           (error) => {
603 675
             const that = this;
@@ -614,6 +686,7 @@ export default {
614 686
               aiMessage.output += '\n\n[回答生成中断]';
615 687
             }
616 688
             that.isLoading = false;
689
+            typewriter.stop()
617 690
 
618 691
             that.$nextTick(() => {
619 692
               that.scrollToBottom();
@@ -628,57 +701,60 @@ export default {
628 701
               window.responseTimeout = null;
629 702
             }
630 703
 
631
-            try {
632
-              // 保存用户消息
633
-              const savedUserMessage = await addChat({
634
-                ...that.chatMessages[userMessageIndex],
635
-                topicId: that.currentTopicId
636
-              });
637
-
638
-              // 保存AI消息
639
-              const savedMessage = await addChat({
640
-                ...aiMessage,
641
-                topicId: that.currentTopicId
642
-              });
643
-
644
-              if (savedUserMessage && savedUserMessage.chatId) {
645
-                that.$set(that.chatMessages, userMessageIndex, {
704
+            typewriter.end(async () => {
705
+              aiMessage.output = trimToLastSentenceEnd(aiMessage.output)
706
+              try {
707
+                // 保存用户消息
708
+                const savedUserMessage = await addChat({
646 709
                   ...that.chatMessages[userMessageIndex],
647
-                  chatId: savedUserMessage.chatId
710
+                  topicId: that.currentTopicId
648 711
                 });
649
-              }
650 712
 
651
-              if (savedMessage && savedMessage.chatId) {
652
-                that.$set(that.chatMessages, messageIndex, {
653
-                  ...that.chatMessages[messageIndex],
654
-                  chatId: savedMessage.chatId
713
+                // 保存AI消息
714
+                const savedMessage = await addChat({
715
+                  ...aiMessage,
716
+                  topicId: that.currentTopicId
655 717
                 });
656
-              }
657 718
 
658
-              if (uploadedFileId) {
659
-                try {
660
-                  const fileResponse = await listDocument({ chatId: uploadedFileId });
661
-                  if (fileResponse.rows && fileResponse.rows.length > 0) {
662
-                    const messageChatId = that.chatMessages[messageIndex].chatId;
663
-                    const keyToUse = messageChatId || uploadedFileId;
664
-                    that.messageFileMap.set(keyToUse, fileResponse.rows);
719
+                if (savedUserMessage && savedUserMessage.chatId) {
720
+                  that.$set(that.chatMessages, userMessageIndex, {
721
+                    ...that.chatMessages[userMessageIndex],
722
+                    chatId: savedUserMessage.chatId
723
+                  });
724
+                }
725
+
726
+                if (savedMessage && savedMessage.chatId) {
727
+                  that.$set(that.chatMessages, messageIndex, {
728
+                    ...that.chatMessages[messageIndex],
729
+                    chatId: savedMessage.chatId
730
+                  });
731
+                }
665 732
 
666
-                    if (!that.chatMessages[messageIndex].chatId) {
667
-                      that.$set(that.chatMessages[messageIndex], 'chatId', uploadedFileId);
733
+                if (uploadedFileId) {
734
+                  try {
735
+                    const fileResponse = await listDocument({ chatId: uploadedFileId });
736
+                    if (fileResponse.rows && fileResponse.rows.length > 0) {
737
+                      const messageChatId = that.chatMessages[messageIndex].chatId;
738
+                      const keyToUse = messageChatId || uploadedFileId;
739
+                      that.messageFileMap.set(keyToUse, fileResponse.rows);
740
+
741
+                      if (!that.chatMessages[messageIndex].chatId) {
742
+                        that.$set(that.chatMessages[messageIndex], 'chatId', uploadedFileId);
743
+                      }
668 744
                     }
745
+                  } catch (error) {
746
+                    console.error('Failed to load files for new message:', error);
669 747
                   }
670
-                } catch (error) {
671
-                  console.error('Failed to load files for new message:', error);
672 748
                 }
749
+              } catch (error) {
750
+                console.error('保存消息失败:', error);
673 751
               }
674
-            } catch (error) {
675
-              console.error('保存消息失败:', error);
676
-            }
677 752
 
678
-            that.isLoading = false;
679
-            that.$nextTick(() => {
680
-              that.scrollToBottom();
681
-            });
753
+              that.isLoading = false;
754
+              that.$nextTick(() => {
755
+                that.scrollToBottom();
756
+              });
757
+            })
682 758
           }
683 759
         );
684 760
       } else {
@@ -686,12 +762,22 @@ export default {
686 762
           streamParams,
687 763
           (content) => {
688 764
             const that = this;
689
-            let cleanContent = content.replace(/<\/?think>/g, '');
690
-            if (!cleanContent.trim()) {
765
+            if (!that.streamingStarted) that.streamingStarted = true;
766
+
767
+            if (!content || !String(content).trim()) {
691 768
               return;
692 769
             }
693
-            aiMessage.output += cleanContent;
694
-            aiMessage.outputTime = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}');
770
+
771
+            // 过滤不完整的句子(以逗号、顿号等结尾)
772
+            const trimmed = String(content).trim();
773
+            if (trimmed.length > 0 && trimmed.length < 30) {
774
+              const lastChar = trimmed.charAt(trimmed.length - 1);
775
+              if (lastChar === ',' || lastChar === '、' || lastChar === ':' || lastChar === ':') {
776
+                return;
777
+              }
778
+            }
779
+
780
+            typewriter.push(trimmed)
695 781
 
696 782
             if (window.responseTimeout) {
697 783
               clearTimeout(window.responseTimeout);
@@ -700,6 +786,7 @@ export default {
700 786
               if (that.isLoading) {
701 787
                 console.log('=== 响应超时强制结束 ===');
702 788
                 that.isLoading = false;
789
+                typewriter.stop()
703 790
                 if (window.responseTimeout) {
704 791
                   clearTimeout(window.responseTimeout);
705 792
                   window.responseTimeout = null;
@@ -707,9 +794,6 @@ export default {
707 794
               }
708 795
             }, 300000);
709 796
 
710
-            that.$nextTick(() => {
711
-              that.scrollToBottom();
712
-            });
713 797
           },
714 798
           (error) => {
715 799
             const that = this;
@@ -726,6 +810,7 @@ export default {
726 810
               aiMessage.output += '\n\n[回答生成中断]';
727 811
             }
728 812
             that.isLoading = false;
813
+            typewriter.stop()
729 814
 
730 815
             that.$nextTick(() => {
731 816
               that.scrollToBottom();
@@ -740,46 +825,55 @@ export default {
740 825
               window.responseTimeout = null;
741 826
             }
742 827
 
743
-            try {
744
-              // 保存用户消息
745
-              const savedUserMessage = await addChat({
746
-                ...that.chatMessages[userMessageIndex],
747
-                topicId: that.currentTopicId
748
-              });
749
-
750
-              // 保存AI消息
751
-              const savedMessage = await addChat({
752
-                ...aiMessage,
753
-                topicId: that.currentTopicId
754
-              });
755
-
756
-              if (savedUserMessage && savedUserMessage.chatId) {
757
-                that.$set(that.chatMessages, userMessageIndex, {
828
+            typewriter.end(async () => {
829
+              aiMessage.output = trimToLastSentenceEnd(aiMessage.output)
830
+              try {
831
+                // 保存用户消息
832
+                const savedUserMessage = await addChat({
758 833
                   ...that.chatMessages[userMessageIndex],
759
-                  chatId: savedUserMessage.chatId
834
+                  topicId: that.currentTopicId
760 835
                 });
761
-              }
762 836
 
763
-              if (savedMessage && savedMessage.chatId) {
764
-                that.$set(that.chatMessages, messageIndex, {
765
-                  ...that.chatMessages[messageIndex],
766
-                  chatId: savedMessage.chatId
837
+                // 保存AI消息
838
+                const savedMessage = await addChat({
839
+                  ...aiMessage,
840
+                  topicId: that.currentTopicId
767 841
                 });
842
+
843
+                if (savedUserMessage && savedUserMessage.chatId) {
844
+                  that.$set(that.chatMessages, userMessageIndex, {
845
+                    ...that.chatMessages[userMessageIndex],
846
+                    chatId: savedUserMessage.chatId
847
+                  });
848
+                }
849
+
850
+                if (savedMessage && savedMessage.chatId) {
851
+                  that.$set(that.chatMessages, messageIndex, {
852
+                    ...that.chatMessages[messageIndex],
853
+                    chatId: savedMessage.chatId
854
+                  });
855
+                }
856
+              } catch (error) {
857
+                console.error('保存消息失败:', error);
768 858
               }
769
-            } catch (error) {
770
-              console.error('保存消息失败:', error);
771
-            }
772 859
 
773
-            that.isLoading = false;
774
-            that.$nextTick(() => {
775
-              that.scrollToBottom();
776
-            });
860
+              that.isLoading = false;
861
+              that.$nextTick(() => {
862
+                that.scrollToBottom();
863
+              });
864
+            })
777 865
           }
778 866
         );
779 867
       }
780 868
 
781 869
       window.currentChatController = streamController;
782 870
     },
871
+    //简化formatMessage方法,不再需要复杂的过滤
872
+    formatMessage(content) {
873
+      // 直接使用marked解析markdown内容,不需要额外过滤
874
+      // 因为后端和session.js已经处理干净了
875
+      return marked(content);
876
+    },
783 877
 
784 878
     handleKeyDown(event) {
785 879
       if (event.key === 'Enter') {
@@ -853,7 +947,7 @@ export default {
853 947
 
854 948
     formatMessageTime(time) {
855 949
       if (!time) return '';
856
-      const date = parseTime(new Date(time), '{y}-{m}-{d}');
950
+      const date = this.parseTime(new Date(time), '{y}-{m}-{d}');
857 951
       return date;
858 952
     },
859 953
 
@@ -1503,7 +1597,8 @@ export default {
1503 1597
 }
1504 1598
 
1505 1599
 /* Markdown样式 */
1506
-.message-bubble >>> .message-text {
1600
+.message-bubble>>>.message-text {
1601
+
1507 1602
   /* 标题样式 */
1508 1603
   h1 {
1509 1604
     font-size: 24px;
@@ -1526,7 +1621,9 @@ export default {
1526 1621
     color: #333;
1527 1622
   }
1528 1623
 
1529
-  h4, h5, h6 {
1624
+  h4,
1625
+  h5,
1626
+  h6 {
1530 1627
     font-size: 16px;
1531 1628
     font-weight: 600;
1532 1629
     margin: 10px 0 6px 0;
@@ -1540,7 +1637,8 @@ export default {
1540 1637
   }
1541 1638
 
1542 1639
   /* 列表样式 */
1543
-  ul, ol {
1640
+  ul,
1641
+  ol {
1544 1642
     margin: 8px 0;
1545 1643
     padding-left: 24px;
1546 1644
   }
@@ -1592,7 +1690,8 @@ export default {
1592 1690
     margin: 12px 0;
1593 1691
   }
1594 1692
 
1595
-  th, td {
1693
+  th,
1694
+  td {
1596 1695
     border: 1px solid #e9ecef;
1597 1696
     padding: 8px 12px;
1598 1697
     text-align: left;

+ 16
- 16
oa-ui/src/views/oa/study/approval.vue Ver fichero

@@ -15,9 +15,9 @@
15 15
           </el-option>
16 16
         </el-select>
17 17
       </el-form-item>
18
-      <el-form-item label="参会日期" prop="reportTime">
19
-        <el-date-picker clearable v-model="queryParams.reportTime" type="date" value-format="yyyy-MM-dd"
20
-          placeholder="请选择参日期">
18
+      <el-form-item label="参培日期" prop="trainTime">
19
+        <el-date-picker clearable v-model="queryParams.trainTime" type="date" value-format="yyyy-MM-dd"
20
+          placeholder="请选择参日期">
21 21
         </el-date-picker>
22 22
       </el-form-item>
23 23
       <el-form-item>
@@ -43,9 +43,9 @@
43 43
       <!-- <el-table-column label="参培id" align="center" prop="participateId" /> -->
44 44
       <el-table-column label="参会人" align="center" prop="reportUser.nickName" />
45 45
       <el-table-column label="部门" align="center" prop="dept.deptName" />
46
-      <el-table-column label="参会日期" align="center" prop="reportTime" width="180">
46
+      <el-table-column label="参培日期" align="center" prop="trainTime" width="180">
47 47
         <template slot-scope="scope">
48
-          <span>{{ parseTime(scope.row.reportTime, '{y}-{m}-{d}') }}</span>
48
+          <span>{{ parseTime(scope.row.trainTime, '{y}-{m}-{d}') }}</span>
49 49
         </template>
50 50
       </el-table-column>
51 51
       <el-table-column label="培训名称" align="center" prop="trainName" />
@@ -79,9 +79,9 @@
79 79
         <el-form-item label="参会人" prop="userId">
80 80
           {{ getUserName(form.userId) }}
81 81
         </el-form-item>
82
-        <el-form-item label="参会日期" prop="reportTime">
83
-          <el-date-picker clearable v-model="form.reportTime" type="date" value-format="yyyy-MM-dd"
84
-            placeholder="请选择参日期">
82
+        <el-form-item label="参培日期" prop="trainTime">
83
+          <el-date-picker clearable v-model="form.trainTime" type="date" value-format="yyyy-MM-dd"
84
+            placeholder="请选择参日期">
85 85
           </el-date-picker>
86 86
         </el-form-item>
87 87
         <el-form-item label="培训名称" prop="trainName">
@@ -126,9 +126,9 @@
126 126
             </el-option>
127 127
           </el-select>
128 128
         </el-form-item>
129
-        <el-form-item label="参会日期" prop="reportTime">
130
-          <el-date-picker clearable v-model="addForm.reportTime" type="date" value-format="yyyy-MM-dd"
131
-            placeholder="请选择参日期">
129
+        <el-form-item label="参培日期" prop="trainTime">
130
+          <el-date-picker clearable v-model="addForm.trainTime" type="date" value-format="yyyy-MM-dd"
131
+            placeholder="请选择参日期">
132 132
           </el-date-picker>
133 133
         </el-form-item>
134 134
         <el-form-item label="学时" prop="hours">
@@ -198,8 +198,8 @@ export default {
198 198
       form: {},
199 199
       // 表单校验
200 200
       rules: {
201
-        reportTime: [
202
-          { required: true, message: "参日期不能为空", trigger: "change" }
201
+        trainTime: [
202
+          { required: true, message: "参日期不能为空", trigger: "change" }
203 203
         ],
204 204
         trainName: [
205 205
           { required: true, message: "培训名称不能为空", trigger: "blur" }
@@ -223,8 +223,8 @@ export default {
223 223
         userIdList: [
224 224
           { required: true, type: 'array', min: 1, message: "请至少选择一位参会人", trigger: "change" }
225 225
         ],
226
-        reportTime: [
227
-          { required: true, message: "参日期不能为空", trigger: "change" }
226
+        trainTime: [
227
+          { required: true, message: "参日期不能为空", trigger: "change" }
228 228
         ],
229 229
         trainName: [
230 230
           { required: true, message: "培训名称不能为空", trigger: "blur" }
@@ -384,7 +384,7 @@ export default {
384 384
               participateId: snowflake.nextId().toString(),
385 385
               userId: userId, // 参会人员ID
386 386
               deptId: deptId, // 用户的部门ID
387
-              reportTime: this.addForm.reportTime, // 当前日期作为参会日期
387
+              trainTime: this.addForm.trainTime, // 当前日期作为参培日期
388 388
               trainName: this.addForm.trainName,
389 389
               content: this.addForm.content,
390 390
               hours: this.addForm.hours,

Loading…
Cancelar
Guardar