|
|
@@ -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) {
|