Browse Source

增加与ai大模型对话

wwh 3 weeks ago
parent
commit
95ac61794f

+ 1 - 2
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/ConversationController.java

@@ -113,8 +113,7 @@ public class ConversationController {
113 113
     @Operation(summary = "会话内 AI 对话(SSE 流式 + 落库)",
114 114
             description = "需先 POST /conversations 创建会话。请求体仅需 message 字段;"
115 115
                     + "服务端组装上游 console/chat 请求,流式开始前写入 user 消息,"
116
-                    + "流式结束后解析 assistant 卡片(content_type/card_payload/next_action)并落库;"
117
-                    + "若 Agent 未返回卡片且会话草稿 status=ready,服务端自动补全 card_draft。"
116
+                    + "SSE complete 后由服务端根据 ready 草稿写入 content_type/card_payload/next_action(不依赖智能体返回卡片),"
118 117
                     + "可选请求头 X-Agent-Id。")
119 118
     public void chat(@PathVariable("id") Long conversationId,
120 119
                      @Validated @RequestBody ConversationChatRequest request,

+ 173 - 21
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/ConsoleChatSseAggregator.java

@@ -2,17 +2,30 @@ package com.huimv.employment.integration.kb;
2 2
 
3 3
 import com.fasterxml.jackson.databind.JsonNode;
4 4
 import com.fasterxml.jackson.databind.ObjectMapper;
5
+import org.springframework.util.StringUtils;
5 6
 
6 7
 import java.nio.charset.StandardCharsets;
8
+import java.util.HashSet;
9
+import java.util.Set;
7 10
 
8 11
 /**
9
- * 从 {@code /api/console/chat} SSE 字节流中增量提取 assistant 文本。
12
+ * 从 {@code /api/console/chat} SSE 字节流中增量提取 assistant 文本,并识别流结束 complete 事件。
13
+ * <p>
14
+ * 兼容:legacy {@code output[]}、{@code object=content} delta、
15
+ * {@code object=response/status=completed}、{@code object=message} 完成态,以及重复 {@code data:} 前缀。
16
+ * </p>
10 17
  */
11 18
 public class ConsoleChatSseAggregator {
12 19
 
13 20
     private final ObjectMapper objectMapper;
14 21
     private final StringBuilder lineBuffer = new StringBuilder();
15
-    private final StringBuilder assistantText = new StringBuilder();
22
+    /** delta 格式拼接(不含 reasoning) */
23
+    private final StringBuilder streamingText = new StringBuilder();
24
+    /** 完成态 assistant 回复(优先返回) */
25
+    private final StringBuilder completedReply = new StringBuilder();
26
+    private final Set<String> reasoningMessageIds = new HashSet<>();
27
+    /** 是否已收到上游 complete / DONE 结束信号 */
28
+    private boolean completed;
16 29
 
17 30
     public ConsoleChatSseAggregator(ObjectMapper objectMapper) {
18 31
         this.objectMapper = objectMapper;
@@ -26,8 +39,26 @@ public class ConsoleChatSseAggregator {
26 39
         drainCompleteLines();
27 40
     }
28 41
 
42
+    /** 流结束时刷新缓冲区中未换行的尾部行。 */
43
+    public void finish() {
44
+        drainCompleteLines();
45
+        if (lineBuffer.length() > 0) {
46
+            consumeSseLine(lineBuffer.toString().trim());
47
+            lineBuffer.setLength(0);
48
+        }
49
+        // 流关闭本身也视为一轮对话结束
50
+        completed = true;
51
+    }
52
+
53
+    public boolean isCompleted() {
54
+        return completed;
55
+    }
56
+
29 57
     public String getAssistantText() {
30
-        return assistantText.toString();
58
+        if (completedReply.length() > 0) {
59
+            return completedReply.toString();
60
+        }
61
+        return streamingText.toString();
31 62
     }
32 63
 
33 64
     private void drainCompleteLines() {
@@ -40,36 +71,157 @@ public class ConsoleChatSseAggregator {
40 71
     }
41 72
 
42 73
     private void consumeSseLine(String line) {
43
-        if (!line.startsWith("data:")) {
74
+        String payload = normalizeDataPayload(line);
75
+        if (!StringUtils.hasText(payload)) {
44 76
             return;
45 77
         }
46
-        String payload = line.substring(5).trim();
47
-        if (payload.isEmpty() || "[DONE]".equals(payload)) {
78
+        if ("[DONE]".equals(payload)) {
79
+            completed = true;
48 80
             return;
49 81
         }
50 82
         try {
51 83
             JsonNode data = objectMapper.readTree(payload);
52
-            JsonNode output = data.get("output");
53
-            if (output == null || !output.isArray()) {
54
-                return;
84
+            consumeJsonEvent(data);
85
+        } catch (Exception ignored) {
86
+            // 跳过无法解析的 SSE 片段,不影响流式转发
87
+        }
88
+    }
89
+
90
+    private void consumeJsonEvent(JsonNode data) {
91
+        if (data == null || data.isNull()) {
92
+            return;
93
+        }
94
+        String objectType = data.path("object").asText("");
95
+        String status = data.path("status").asText("");
96
+
97
+        if ("message".equals(objectType) && "reasoning".equals(data.path("type").asText())) {
98
+            String messageId = data.path("id").asText(null);
99
+            if (StringUtils.hasText(messageId)) {
100
+                reasoningMessageIds.add(messageId);
55 101
             }
56
-            for (JsonNode item : output) {
57
-                if (!"assistant".equals(item.path("role").asText())) {
58
-                    continue;
102
+            return;
103
+        }
104
+
105
+        // 最后一条 complete:response 完成态
106
+        if (("response".equals(objectType) || "chat.completion".equals(objectType))
107
+                && ("completed".equals(status) || "complete".equals(status))) {
108
+            completed = true;
109
+            if (data.has("output")) {
110
+                String reply = extractAssistantReplyFromOutput(data.get("output"));
111
+                if (StringUtils.hasText(reply)) {
112
+                    completedReply.setLength(0);
113
+                    completedReply.append(reply);
114
+                }
115
+            }
116
+            return;
117
+        }
118
+
119
+        // 兼容 object=complete / type=complete
120
+        if ("complete".equals(objectType)
121
+                || "complete".equals(data.path("type").asText())
122
+                || "completed".equals(data.path("type").asText())) {
123
+            completed = true;
124
+            if (data.has("output")) {
125
+                String reply = extractAssistantReplyFromOutput(data.get("output"));
126
+                if (StringUtils.hasText(reply)) {
127
+                    completedReply.setLength(0);
128
+                    completedReply.append(reply);
59 129
                 }
60
-                JsonNode contents = item.get("content");
61
-                if (contents == null || !contents.isArray()) {
62
-                    continue;
130
+            }
131
+            return;
132
+        }
133
+
134
+        if ("message".equals(objectType)
135
+                && "message".equals(data.path("type").asText())
136
+                && "assistant".equals(data.path("role").asText())) {
137
+            String reply = extractTextFromContentNodes(data.get("content"));
138
+            if (StringUtils.hasText(reply)) {
139
+                completedReply.setLength(0);
140
+                completedReply.append(reply);
141
+            }
142
+            if ("completed".equals(status) || "complete".equals(status)) {
143
+                completed = true;
144
+            }
145
+            return;
146
+        }
147
+
148
+        if ("content".equals(objectType) && "text".equals(data.path("type").asText())) {
149
+            if (data.path("delta").asBoolean(false) && data.has("text")) {
150
+                String msgId = data.path("msg_id").asText(null);
151
+                if (StringUtils.hasText(msgId) && reasoningMessageIds.contains(msgId)) {
152
+                    return;
63 153
                 }
64
-                for (JsonNode content : contents) {
65
-                    if ("text".equals(content.path("type").asText()) && content.has("text")) {
66
-                        assistantText.append(content.get("text").asText(""));
67
-                    }
154
+                streamingText.append(data.get("text").asText(""));
155
+            }
156
+            return;
157
+        }
158
+
159
+        JsonNode output = data.get("output");
160
+        if (output != null && output.isArray()) {
161
+            String reply = extractAssistantReplyFromOutput(output);
162
+            if (StringUtils.hasText(reply) && completedReply.length() == 0 && streamingText.length() == 0) {
163
+                streamingText.append(reply);
164
+            }
165
+        }
166
+    }
167
+
168
+    private static String extractAssistantReplyFromOutput(JsonNode output) {
169
+        if (output == null || !output.isArray()) {
170
+            return "";
171
+        }
172
+        StringBuilder reply = new StringBuilder();
173
+        for (JsonNode item : output) {
174
+            if (!"assistant".equals(item.path("role").asText())) {
175
+                continue;
176
+            }
177
+            if (!"message".equals(item.path("type").asText("message"))) {
178
+                continue;
179
+            }
180
+            String text = extractTextFromContentNodes(item.get("content"));
181
+            if (StringUtils.hasText(text)) {
182
+                if (reply.length() > 0) {
183
+                    reply.append('\n');
68 184
                 }
185
+                reply.append(text);
69 186
             }
70
-        } catch (Exception ignored) {
71
-            // 跳过无法解析的 SSE 片段,不影响流式转发
72 187
         }
188
+        return reply.toString();
189
+    }
190
+
191
+    private static String extractTextFromContentNodes(JsonNode contents) {
192
+        if (contents == null || !contents.isArray()) {
193
+            return "";
194
+        }
195
+        StringBuilder text = new StringBuilder();
196
+        for (JsonNode content : contents) {
197
+            appendTextPart(text, content);
198
+        }
199
+        return text.toString();
200
+    }
201
+
202
+    private static void appendTextPart(StringBuilder target, JsonNode content) {
203
+        if (content == null || content.isNull()) {
204
+            return;
205
+        }
206
+        if (content.has("text") && "text".equals(content.path("type").asText("text"))) {
207
+            target.append(content.get("text").asText(""));
208
+            return;
209
+        }
210
+        if (content.has("text") && content.has("object") && "content".equals(content.path("object").asText())) {
211
+            target.append(content.get("text").asText(""));
212
+        }
213
+    }
214
+
215
+    /** 去掉一层或多层 {@code data:} 前缀,得到 JSON 载荷。 */
216
+    static String normalizeDataPayload(String line) {
217
+        if (!StringUtils.hasText(line)) {
218
+            return "";
219
+        }
220
+        String payload = line.trim();
221
+        while (payload.startsWith("data:")) {
222
+            payload = payload.substring(5).trim();
223
+        }
224
+        return payload;
73 225
     }
74 226
 
75 227
     private static int indexOfLineBreak(StringBuilder sb) {

+ 4 - 2
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyService.java

@@ -18,7 +18,9 @@ public interface KbOpenAiProxyService {
18 18
 
19 19
     /**
20 20
      * 流式转发 SSE 并聚合 assistant 文本(供会话落库场景使用,不影响纯透传接口)。
21
+     *
22
+     * @return 助手正文 + 是否收到 complete / 流结束
21 23
      */
22
-    String streamConsoleChatCollecting(byte[] requestBody, String agentId, OutputStream output,
23
-                                       ObjectMapper objectMapper) throws IOException;
24
+    ConsoleChatCollectResult streamConsoleChatCollecting(byte[] requestBody, String agentId, OutputStream output,
25
+                                                         ObjectMapper objectMapper) throws IOException;
24 26
 }

+ 4 - 3
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyServiceImpl.java

@@ -62,11 +62,12 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
62 62
     }
63 63
 
64 64
     @Override
65
-    public String streamConsoleChatCollecting(byte[] requestBody, String agentId, OutputStream output,
66
-                                              ObjectMapper objectMapper) throws IOException {
65
+    public ConsoleChatCollectResult streamConsoleChatCollecting(byte[] requestBody, String agentId, OutputStream output,
66
+                                                                ObjectMapper objectMapper) throws IOException {
67 67
         ConsoleChatSseAggregator aggregator = new ConsoleChatSseAggregator(objectMapper);
68 68
         streamConsoleChatInternal(requestBody, agentId, output, aggregator);
69
-        return aggregator.getAssistantText();
69
+        aggregator.finish();
70
+        return new ConsoleChatCollectResult(aggregator.getAssistantText(), aggregator.isCompleted());
70 71
     }
71 72
 
72 73
     private void streamConsoleChatInternal(byte[] requestBody, String agentId, OutputStream output,

+ 14 - 3
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationChatService.java

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
4 4
 import com.fasterxml.jackson.databind.node.ArrayNode;
5 5
 import com.fasterxml.jackson.databind.node.ObjectNode;
6 6
 import com.huimv.employment.dao.entity.FeConversation;
7
+import com.huimv.employment.integration.kb.ConsoleChatCollectResult;
7 8
 import com.huimv.employment.integration.kb.KbOpenAiProxyService;
8 9
 import com.huimv.employment.service.conversation.dto.ConversationChatRequest;
9 10
 import com.huimv.employment.service.conversation.dto.ParsedAssistantReply;
@@ -17,6 +18,9 @@ import java.io.OutputStream;
17 18
 
18 19
 /**
19 20
  * 会话内 AI 对话:组装上游请求、流式转发并在结束后落库。
21
+ * <p>
22
+ * 卡片字段由服务端在 SSE complete 后根据草稿状态写入,不依赖智能体返回 card JSON。
23
+ * </p>
20 24
  */
21 25
 @Service
22 26
 public class ConversationChatService {
@@ -59,13 +63,20 @@ public class ConversationChatService {
59 63
             conversationIntentService.classifyAndUpdateTitleAsync(
60 64
                     conversation.getConversationNo(), userText, agentId);
61 65
         }
62
-        String assistantText = kbOpenAiProxyService.streamConsoleChatCollecting(
66
+
67
+        ConsoleChatCollectResult collectResult = kbOpenAiProxyService.streamConsoleChatCollecting(
63 68
                 upstreamBody, agentId, output, objectMapper);
64
-        ParsedAssistantReply parsed = AssistantReplyParser.parse(assistantText, objectMapper);
65
-        parsed = conversationDraftCardEnricher.enrichIfReady(conversation.getId(), parsed);
69
+        // SSE complete(或流正常关闭)后:服务端按 ready 草稿写入卡片,忽略智能体卡片 JSON
70
+        ParsedAssistantReply parsed = conversationDraftCardEnricher.attachCardOnComplete(
71
+                conversation.getId(), collectResult.getAssistantText());
72
+
66 73
         if (!StringUtils.hasText(parsed.getDisplayText()) && !parsed.hasCard()) {
67 74
             log.warn("assistant 回复为空,未写入 fe_conversation_message conversationId={} conversationNo={}",
68 75
                     conversation.getId(), conversation.getConversationNo());
76
+        } else if (parsed.hasCard()) {
77
+            log.info("SSE complete 落库卡片 conversationId={} completed={} contentType={} relatedDraftId={}",
78
+                    conversation.getId(), collectResult.isCompleted(),
79
+                    parsed.getContentType(), parsed.getRelatedDraftId());
69 80
         }
70 81
         conversationMessageService.saveAssistantMessage(conversation.getId(), parsed);
71 82
     }

+ 31 - 6
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationDraftCardEnricher.java

@@ -15,9 +15,10 @@ import java.util.Collections;
15 15
 import java.util.LinkedHashMap;
16 16
 import java.util.List;
17 17
 import java.util.Map;
18
+import java.util.regex.Pattern;
18 19
 
19 20
 /**
20
- * 当 Agent 未返回卡片 JSON 时,根据会话 pending 且 ready 的草稿自动补全 card_draft 消息
21
+ * SSE complete 后,由服务端根据会话 pending+ready 草稿写入卡片字段(不依赖智能体返回 card JSON)
21 22
  */
22 23
 @Component
23 24
 public class ConversationDraftCardEnricher {
@@ -26,6 +27,11 @@ public class ConversationDraftCardEnricher {
26 27
 
27 28
     private static final String DB_STATUS_PENDING = "pending";
28 29
 
30
+    /** 剥除智能体可能附带的 fe-card / json 代码块,仅保留展示文案 */
31
+    private static final Pattern FENCED_CARD_BLOCK = Pattern.compile(
32
+            "```(?:fe-card|json)?\\s*[\\s\\S]*?```",
33
+            Pattern.CASE_INSENSITIVE);
34
+
29 35
     private final FeEmploymentDraftMapper feEmploymentDraftMapper;
30 36
     private final ObjectMapper objectMapper;
31 37
 
@@ -35,29 +41,48 @@ public class ConversationDraftCardEnricher {
35 41
         this.objectMapper = objectMapper;
36 42
     }
37 43
 
38
-    public ParsedAssistantReply enrichIfReady(Long conversationId, ParsedAssistantReply parsed) {
39
-        if (parsed == null || parsed.hasCard() || conversationId == null) {
44
+    /**
45
+     * SSE 流结束(complete)后调用:若草稿已 ready,强制写入 card_draft 字段。
46
+     */
47
+    public ParsedAssistantReply attachCardOnComplete(Long conversationId, String assistantText) {
48
+        String displayText = sanitizeDisplayText(assistantText);
49
+        ParsedAssistantReply parsed = ParsedAssistantReply.textOnly(displayText);
50
+        if (conversationId == null) {
40 51
             return parsed;
41 52
         }
42 53
         FeEmploymentDraft draft = findPendingDraft(conversationId);
43
-        if (draft == null || !isReady(draft)) {
54
+        if (draft == null) {
55
+            log.debug("会话 {} SSE complete 后无 pending 草稿,按纯文本落库", conversationId);
56
+            return parsed;
57
+        }
58
+        if (!isReady(draft)) {
59
+            log.debug("会话 {} 草稿 {} 尚未 ready(missing_fields={}),按纯文本落库",
60
+                    conversationId, draft.getId(), draft.getMissingFields());
44 61
             return parsed;
45 62
         }
46 63
         try {
47 64
             Map<String, Object> payload = buildDraftCardPayload(draft);
48 65
             String cardPayload = objectMapper.writeValueAsString(payload);
49
-            log.debug("会话 {} 草稿 {} 已 ready,自动补全 card_draft 消息", conversationId, draft.getId());
66
+            log.info("会话 {} SSE complete,服务端写入 card_draft draftId={}", conversationId, draft.getId());
50 67
             return parsed.withCard(
51 68
                     MessageCardConstants.CONTENT_TYPE_CARD_DRAFT,
52 69
                     MessageCardConstants.NEXT_ACTION_SHOW_EMPLOYMENT_DRAFT,
53 70
                     cardPayload,
54 71
                     draft.getId());
55 72
         } catch (Exception ex) {
56
-            log.warn("会话 {} 草稿卡片补全失败: {}", conversationId, ex.getMessage());
73
+            log.warn("会话 {} 草稿卡片写入失败: {}", conversationId, ex.getMessage());
57 74
             return parsed;
58 75
         }
59 76
     }
60 77
 
78
+    private String sanitizeDisplayText(String raw) {
79
+        if (!StringUtils.hasText(raw)) {
80
+            return "";
81
+        }
82
+        String cleaned = FENCED_CARD_BLOCK.matcher(raw).replaceAll("").trim();
83
+        return StringUtils.hasText(cleaned) ? cleaned : raw.trim();
84
+    }
85
+
61 86
     private FeEmploymentDraft findPendingDraft(Long conversationId) {
62 87
         return feEmploymentDraftMapper.selectOne(new LambdaQueryWrapper<FeEmploymentDraft>()
63 88
                 .eq(FeEmploymentDraft::getConversationId, conversationId)