Sfoglia il codice sorgente

增加与ai大模型对话

wwh 3 settimane fa
parent
commit
cbc808f11b

+ 44 - 30
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/AiProxyController.java

@@ -1,72 +1,86 @@
1 1
 package com.huimv.employment.controller.mp;
2 2
 
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.fasterxml.jackson.databind.node.ObjectNode;
3 6
 import com.huimv.employment.integration.kb.KbOpenAiProxyService;
4 7
 import com.huimv.employment.integration.kb.SseStreamSupport;
8
+import com.huimv.employment.security.LoginUser;
9
+import com.huimv.employment.security.LoginUserHolder;
5 10
 import io.swagger.v3.oas.annotations.Operation;
6 11
 import io.swagger.v3.oas.annotations.security.SecurityRequirement;
7 12
 import io.swagger.v3.oas.annotations.tags.Tag;
8 13
 import org.springframework.http.MediaType;
9 14
 import org.springframework.http.ResponseEntity;
10 15
 import org.springframework.util.StreamUtils;
16
+import org.springframework.util.StringUtils;
11 17
 import org.springframework.web.bind.annotation.GetMapping;
12 18
 import org.springframework.web.bind.annotation.PostMapping;
19
+import org.springframework.web.bind.annotation.RequestHeader;
13 20
 import org.springframework.web.bind.annotation.RequestMapping;
14 21
 import org.springframework.web.bind.annotation.RestController;
15 22
 
16 23
 import javax.servlet.http.HttpServletRequest;
17 24
 import javax.servlet.http.HttpServletResponse;
18 25
 import java.io.IOException;
19
-import java.nio.charset.StandardCharsets;
20 26
 
21 27
 /**
22
- * 大模型 OpenAI 兼容接口代理(小程序 JWT 鉴权,上游由服务端 fe.kb.api-key 鉴权)。
23
- * <p>
24
- * 支持 {@code stream=true} 的 SSE 流式响应,请求/响应体与 OpenAI Chat Completions 规范一致。
25
- * </p>
28
+ * 智能体网关代理(小程序 JWT 鉴权;上游暂不需要 api-key)。
26 29
  */
27 30
 @RestController
28 31
 @RequestMapping("/api/v1/mp/ai")
29
-@Tag(name = "AI 对话", description = "OpenAI 兼容代理,需 JWT 鉴权")
32
+@Tag(name = "AI 对话", description = "智能体网关代理,需 JWT 鉴权")
30 33
 @SecurityRequirement(name = "Authorization")
31 34
 public class AiProxyController {
32 35
 
36
+    private static final String HEADER_AGENT_ID = "X-Agent-Id";
37
+
33 38
     private final KbOpenAiProxyService kbOpenAiProxyService;
39
+    private final ObjectMapper objectMapper;
34 40
 
35
-    public AiProxyController(KbOpenAiProxyService kbOpenAiProxyService) {
41
+    public AiProxyController(KbOpenAiProxyService kbOpenAiProxyService, ObjectMapper objectMapper) {
36 42
         this.kbOpenAiProxyService = kbOpenAiProxyService;
43
+        this.objectMapper = objectMapper;
37 44
     }
38 45
 
39 46
     @GetMapping("/version")
40
-    @Operation(summary = "获取版本信息", description = "OpenAI 兼容 GET /api/version,透传上游 KB 服务响应。")
47
+    @Operation(summary = "获取版本信息", description = "透传上游 GET /api/version,用于健康检查。")
41 48
     public ResponseEntity<byte[]> getVersion() {
42 49
         return kbOpenAiProxyService.getVersion();
43 50
     }
44 51
 
45 52
     @PostMapping(value = "/console/chat", consumes = MediaType.APPLICATION_JSON_VALUE)
46
-    @Operation(summary = "对话补全(OpenAI 兼容)",
47
-            description = "请求/响应体与 OpenAI Chat Completions 规范一致,透传上游 KB 服务。"
48
-                    + "请求体示例:{\"model\":\"gpt-4\",\"messages\":[{\"role\":\"user\",\"content\":\"你好\"}],\"stream\":false}"
49
-                    + ";stream=true 时返回 text/event-stream SSE 流式响应。")
50
-    public void consoleChat(HttpServletRequest request, HttpServletResponse response) throws IOException {
51
-        byte[] body = StreamUtils.copyToByteArray(request.getInputStream());
52
-        if (kbOpenAiProxyService.isStreamRequest(body)) {
53
-            SseStreamSupport.prepareSseResponse(response);
54
-            kbOpenAiProxyService.streamChatCompletions(body, response.getOutputStream());
55
-            response.flushBuffer();
56
-            return;
53
+    @Operation(summary = "智能体对话(SSE 流式)",
54
+            description = "透传上游 POST /api/console/chat,固定返回 text/event-stream。"
55
+                    + "请求体示例:{\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"你好\"}]}],"
56
+                    + "\"session_id\":\"demo-001\",\"user_id\":\"1001\",\"channel\":\"console\"}。"
57
+                    + "未传 user_id 时自动填充当前登录用户 ID;未传 channel 时默认为 console。"
58
+                    + "可选请求头 X-Agent-Id,默认 default。")
59
+    public void consoleChat(HttpServletRequest request,
60
+                            HttpServletResponse response,
61
+                            @RequestHeader(value = HEADER_AGENT_ID, required = false) String agentId) throws IOException {
62
+        byte[] body = enrichConsoleChatBody(StreamUtils.copyToByteArray(request.getInputStream()));
63
+        SseStreamSupport.prepareSseResponse(response);
64
+        kbOpenAiProxyService.streamConsoleChat(body, agentId, response.getOutputStream());
65
+        response.flushBuffer();
66
+    }
67
+
68
+    private byte[] enrichConsoleChatBody(byte[] body) throws IOException {
69
+        if (body == null || body.length == 0) {
70
+            return body;
71
+        }
72
+        JsonNode root = objectMapper.readTree(body);
73
+        if (!(root instanceof ObjectNode)) {
74
+            return body;
57 75
         }
58
-        ResponseEntity<byte[]> upstream = kbOpenAiProxyService.consoleChat(body);
59
-        response.setStatus(upstream.getStatusCodeValue());
60
-        MediaType contentType = upstream.getHeaders().getContentType();
61
-        if (contentType != null) {
62
-            response.setContentType(contentType.toString());
63
-        } else {
64
-            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
65
-            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
76
+        ObjectNode objectNode = (ObjectNode) root;
77
+        if (!StringUtils.hasText(objectNode.path("user_id").asText(null))) {
78
+            LoginUser loginUser = LoginUserHolder.require();
79
+            objectNode.put("user_id", String.valueOf(loginUser.getUserId()));
66 80
         }
67
-        byte[] respBody = upstream.getBody();
68
-        if (respBody != null && respBody.length > 0) {
69
-            response.getOutputStream().write(respBody);
81
+        if (!StringUtils.hasText(objectNode.path("channel").asText(null))) {
82
+            objectNode.put("channel", "console");
70 83
         }
84
+        return objectMapper.writeValueAsBytes(objectNode);
71 85
     }
72 86
 }

+ 1 - 1
huimv-employment/fe-api/src/main/resources/application.yml

@@ -57,8 +57,8 @@ fe:
57 57
     scheme: http
58 58
     host: harrison1.iask.in
59 59
     port: 9188
60
-    api-key: ${FE_KB_API_KEY:}
61 60
     default-model: default
61
+    default-agent-id: default
62 62
     default-knowledge-base: default
63 63
     file-url-base: ""
64 64
     upload-path: ${FE_KB_UPLOAD_PATH:}

+ 72 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/ConsoleChatSseParser.java

@@ -0,0 +1,72 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.huimv.employment.common.exception.BizException;
6
+import com.huimv.employment.common.exception.ErrorCode;
7
+
8
+import java.io.BufferedReader;
9
+import java.io.IOException;
10
+import java.io.InputStream;
11
+import java.io.InputStreamReader;
12
+import java.nio.charset.StandardCharsets;
13
+
14
+/**
15
+ * 解析 {@code POST /api/console/chat} 返回的 SSE 事件,提取 assistant 文本。
16
+ */
17
+public final class ConsoleChatSseParser {
18
+
19
+    private ConsoleChatSseParser() {
20
+    }
21
+
22
+    public static String extractAssistantText(InputStream sseStream, ObjectMapper objectMapper) throws IOException {
23
+        if (sseStream == null) {
24
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型返回空响应");
25
+        }
26
+        StringBuilder full = new StringBuilder();
27
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(sseStream, StandardCharsets.UTF_8))) {
28
+            String line;
29
+            while ((line = reader.readLine()) != null) {
30
+                if (!line.startsWith("data: ")) {
31
+                    continue;
32
+                }
33
+                String payload = line.substring(6).trim();
34
+                if (payload.isEmpty() || "[DONE]".equals(payload)) {
35
+                    continue;
36
+                }
37
+                JsonNode data = objectMapper.readTree(payload);
38
+                JsonNode error = data.get("error");
39
+                if (error != null && !error.isNull()) {
40
+                    String message = error.path("message").asText("大模型返回错误");
41
+                    throw new BizException(ErrorCode.AI_SERVICE_FAILED, message);
42
+                }
43
+                JsonNode output = data.get("output");
44
+                if (output == null || !output.isArray()) {
45
+                    continue;
46
+                }
47
+                for (JsonNode item : output) {
48
+                    if (!"assistant".equals(item.path("role").asText())) {
49
+                        continue;
50
+                    }
51
+                    JsonNode contents = item.get("content");
52
+                    if (contents == null || !contents.isArray()) {
53
+                        continue;
54
+                    }
55
+                    for (JsonNode content : contents) {
56
+                        if ("text".equals(content.path("type").asText()) && content.has("text")) {
57
+                            full.append(content.get("text").asText(""));
58
+                        }
59
+                    }
60
+                }
61
+            }
62
+        } catch (BizException ex) {
63
+            throw ex;
64
+        } catch (Exception ex) {
65
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "解析大模型 SSE 响应失败");
66
+        }
67
+        if (full.length() == 0) {
68
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型未返回有效内容");
69
+        }
70
+        return full.toString();
71
+    }
72
+}

+ 25 - 1
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbApiProperties.java

@@ -1,6 +1,7 @@
1 1
 package com.huimv.employment.integration.kb;
2 2
 
3 3
 import org.springframework.boot.context.properties.ConfigurationProperties;
4
+import org.springframework.http.HttpHeaders;
4 5
 
5 6
 /**
6 7
  * 大模型网关 HTTP 对接(知识库 {@code /api/v1/kb/**}、对话 {@code /v1/models}、{@code /v1/chat/completions}),绑定 {@code fe.kb.*}。
@@ -17,7 +18,7 @@ public class KbApiProperties {
17 18
 
18 19
     private int port = 9107;
19 20
 
20
-    /** 访问令牌(配置原始 key,请求头自动加 Bearer 前缀) */
21
+    /** 访问令牌(暂不需要;外网远程访问时再配置 fe.kb.api-key) */
21 22
     private String apiKey = "";
22 23
 
23 24
     private String authHeaderName = "Authorization";
@@ -33,6 +34,9 @@ public class KbApiProperties {
33 34
     /** agent/messages 等非透传场景默认模型 */
34 35
     private String defaultModel = "default";
35 36
 
37
+    /** 上游 {@code X-Agent-Id} 默认值(见智能体接口文档) */
38
+    private String defaultAgentId = "default";
39
+
36 40
     /**
37 41
      * Markdown 模板与生成文件根目录(对应若依 ruoyi.profile)。
38 42
      * 为空时 {@link KbMarkdownGenerator} 不可用。
@@ -132,6 +136,14 @@ public class KbApiProperties {
132 136
         this.defaultModel = defaultModel;
133 137
     }
134 138
 
139
+    public String getDefaultAgentId() {
140
+        return defaultAgentId;
141
+    }
142
+
143
+    public void setDefaultAgentId(String defaultAgentId) {
144
+        this.defaultAgentId = defaultAgentId;
145
+    }
146
+
135 147
     public String getUploadPath() {
136 148
         return uploadPath;
137 149
     }
@@ -196,6 +208,18 @@ public class KbApiProperties {
196 208
         this.retryIntervalMs = retryIntervalMs;
197 209
     }
198 210
 
211
+    /** 是否配置了上游 Bearer 鉴权。 */
212
+    public boolean hasUpstreamAuth() {
213
+        return apiKey != null && !apiKey.trim().isEmpty();
214
+    }
215
+
216
+    /** 配置了 api-key 时写入 Authorization 请求头。 */
217
+    public void applyAuthHeader(HttpHeaders headers) {
218
+        if (hasUpstreamAuth()) {
219
+            headers.add(authHeaderName, authHeaderValue());
220
+        }
221
+    }
222
+
199 223
     /** 请求头鉴权值,默认 {@code Bearer {apiKey}}。 */
200 224
     public String authHeaderValue() {
201 225
         if (apiKey == null || apiKey.isEmpty()) {

+ 3 - 5
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyService.java

@@ -6,15 +6,13 @@ import java.io.IOException;
6 6
 import java.io.OutputStream;
7 7
 
8 8
 /**
9
- * 大模型 OpenAI 兼容接口转发({@code /api/version}、{@code /api/console/chat})。
9
+ * 智能体网关转发({@code GET /api/version}、{@code POST /api/console/chat})。
10 10
  */
11 11
 public interface KbOpenAiProxyService {
12 12
 
13 13
     ResponseEntity<byte[]> getVersion();
14 14
 
15
-    ResponseEntity<byte[]> consoleChat(byte[] requestBody);
15
+    void streamConsoleChat(byte[] requestBody, String agentId, OutputStream output) throws IOException;
16 16
 
17
-    boolean isStreamRequest(byte[] requestBody);
18
-
19
-    void streamChatCompletions(byte[] requestBody, OutputStream output) throws IOException;
17
+    String collectConsoleChatReply(byte[] requestBody, String agentId);
20 18
 }

+ 104 - 57
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyServiceImpl.java

@@ -1,7 +1,5 @@
1 1
 package com.huimv.employment.integration.kb;
2 2
 
3
-import com.fasterxml.jackson.databind.JsonNode;
4
-import com.fasterxml.jackson.databind.ObjectMapper;
5 3
 import com.huimv.employment.common.exception.BizException;
6 4
 import com.huimv.employment.common.exception.ErrorCode;
7 5
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -24,21 +22,22 @@ import java.net.URL;
24 22
 import java.nio.charset.StandardCharsets;
25 23
 
26 24
 /**
27
- * 将 OpenAI 兼容对话/模型列表接口转发至上游大模型网关。
25
+ * 将智能体 {@code /api/version}、{@code /api/console/chat} 转发至上游网关。
28 26
  */
29 27
 @Service
30 28
 public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
31 29
 
30
+    private static final String HEADER_AGENT_ID = "X-Agent-Id";
32 31
     private static final String PATH_VERSION = "/api/version";
33 32
     private static final String PATH_CHAT = "/api/console/chat";
34 33
 
35 34
     private final KbApiProperties properties;
36 35
     private final RestTemplate restTemplate;
37
-    private final ObjectMapper objectMapper;
36
+    private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
38 37
 
39 38
     public KbOpenAiProxyServiceImpl(KbApiProperties properties,
40 39
                                     @Qualifier("kbRestTemplate") RestTemplate restTemplate,
41
-                                    ObjectMapper objectMapper) {
40
+                                    com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
42 41
         this.properties = properties;
43 42
         this.restTemplate = restTemplate;
44 43
         this.objectMapper = objectMapper;
@@ -46,12 +45,12 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
46 45
 
47 46
     @Override
48 47
     public ResponseEntity<byte[]> getVersion() {
49
-        ensureReady();
48
+        ensureEnabled();
50 49
         try {
51 50
             ResponseEntity<byte[]> upstream = restTemplate.exchange(
52 51
                     properties.baseUrl() + PATH_VERSION,
53 52
                     HttpMethod.GET,
54
-                    new HttpEntity<>(upstreamAuthHeaders()),
53
+                    new HttpEntity<>(upstreamHeaders(null, false)),
55 54
                     byte[].class);
56 55
             return forward(upstream);
57 56
         } catch (HttpStatusCodeException ex) {
@@ -60,38 +59,16 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
60 59
     }
61 60
 
62 61
     @Override
63
-    public ResponseEntity<byte[]> consoleChat(byte[] requestBody) {
64
-        ensureReady();
65
-        if (requestBody == null || requestBody.length == 0) {
66
-            throw new BizException(ErrorCode.BAD_REQUEST, "请求体不能为空");
67
-        }
68
-        try {
69
-            HttpHeaders headers = upstreamAuthHeaders();
70
-            headers.setContentType(MediaType.APPLICATION_JSON);
71
-            ResponseEntity<byte[]> upstream = restTemplate.exchange(
72
-                    properties.baseUrl() + PATH_CHAT,
73
-                    HttpMethod.POST,
74
-                    new HttpEntity<>(requestBody, headers),
75
-                    byte[].class);
76
-            return forward(upstream);
77
-        } catch (HttpStatusCodeException ex) {
78
-            return forwardError(ex);
79
-        }
80
-    }
81
-
82
-    @Override
83
-    public void streamChatCompletions(byte[] requestBody, OutputStream output) throws IOException {
84
-        ensureReady();
85
-        if (requestBody == null || requestBody.length == 0) {
86
-            throw new BizException(ErrorCode.BAD_REQUEST, "请求体不能为空");
87
-        }
62
+    public void streamConsoleChat(byte[] requestBody, String agentId, OutputStream output) throws IOException {
63
+        ensureEnabled();
64
+        validateRequestBody(requestBody);
88 65
         HttpURLConnection conn = null;
89 66
         InputStream input = null;
90 67
         try {
91 68
             conn = openUpstreamConnection(requestBody.length);
92 69
             conn.setRequestProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
93 70
             conn.setRequestProperty(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE);
94
-            conn.setRequestProperty(properties.getAuthHeaderName(), properties.authHeaderValue());
71
+            applyUpstreamAuth(conn, agentId);
95 72
             conn.setDoOutput(true);
96 73
             try (OutputStream upstreamOut = conn.getOutputStream()) {
97 74
                 upstreamOut.write(requestBody);
@@ -100,16 +77,7 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
100 77
             input = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
101 78
             if (input != null) {
102 79
                 if (status >= 400) {
103
-                    byte[] errBytes = StreamUtils.copyToByteArray(input);
104
-                    if (errBytes.length > 0) {
105
-                        String errText = new String(errBytes, StandardCharsets.UTF_8).trim();
106
-                        if (errText.startsWith("data:") || errText.contains("\n\n")) {
107
-                            output.write(errBytes);
108
-                            output.flush();
109
-                        } else {
110
-                            SseStreamSupport.writeDataEvent(output, errText);
111
-                        }
112
-                    }
80
+                    writeUpstreamError(output, input);
113 81
                 } else {
114 82
                     SseStreamSupport.pipeWithFlush(input, output);
115 83
                 }
@@ -129,16 +97,41 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
129 97
     }
130 98
 
131 99
     @Override
132
-    public boolean isStreamRequest(byte[] requestBody) {
133
-        if (requestBody == null || requestBody.length == 0) {
134
-            return false;
135
-        }
100
+    public String collectConsoleChatReply(byte[] requestBody, String agentId) {
101
+        ensureEnabled();
102
+        validateRequestBody(requestBody);
103
+        HttpURLConnection conn = null;
104
+        InputStream input = null;
136 105
         try {
137
-            JsonNode root = objectMapper.readTree(requestBody);
138
-            JsonNode stream = root.get("stream");
139
-            return stream != null && stream.asBoolean(false);
106
+            conn = openUpstreamConnection(requestBody.length);
107
+            conn.setRequestProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
108
+            conn.setRequestProperty(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE);
109
+            applyUpstreamAuth(conn, agentId);
110
+            conn.setDoOutput(true);
111
+            try (OutputStream upstreamOut = conn.getOutputStream()) {
112
+                upstreamOut.write(requestBody);
113
+            }
114
+            int status = conn.getResponseCode();
115
+            input = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
116
+            if (status >= 400) {
117
+                throw upstreamHttpError(status, input);
118
+            }
119
+            return ConsoleChatSseParser.extractAssistantText(input, objectMapper);
120
+        } catch (BizException ex) {
121
+            throw ex;
140 122
         } catch (Exception ex) {
141
-            return false;
123
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "调用大模型失败");
124
+        } finally {
125
+            if (input != null) {
126
+                try {
127
+                    input.close();
128
+                } catch (IOException ignored) {
129
+                    // ignore
130
+                }
131
+            }
132
+            if (conn != null) {
133
+                conn.disconnect();
134
+            }
142 135
         }
143 136
     }
144 137
 
@@ -152,18 +145,72 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
152 145
         return conn;
153 146
     }
154 147
 
155
-    private void ensureReady() {
148
+    private void applyUpstreamAuth(HttpURLConnection conn, String agentId) {
149
+        conn.setRequestProperty(HEADER_AGENT_ID, resolveAgentId(agentId));
150
+        if (properties.hasUpstreamAuth()) {
151
+            conn.setRequestProperty(properties.getAuthHeaderName(), properties.authHeaderValue());
152
+        }
153
+    }
154
+
155
+    private void writeUpstreamError(OutputStream output, InputStream input) throws IOException {
156
+        byte[] errBytes = StreamUtils.copyToByteArray(input);
157
+        if (errBytes.length == 0) {
158
+            return;
159
+        }
160
+        String errText = new String(errBytes, StandardCharsets.UTF_8).trim();
161
+        if (errText.startsWith("data:") || errText.contains("\n\n")) {
162
+            output.write(errBytes);
163
+            output.flush();
164
+        } else {
165
+            SseStreamSupport.writeDataEvent(output, errText);
166
+        }
167
+    }
168
+
169
+    private BizException upstreamHttpError(int status, InputStream input) throws IOException {
170
+        if (input != null) {
171
+            byte[] errBytes = StreamUtils.copyToByteArray(input);
172
+            if (errBytes.length > 0) {
173
+                String errText = new String(errBytes, StandardCharsets.UTF_8).trim();
174
+                if (status == 401) {
175
+                    String hint = properties.hasUpstreamAuth()
176
+                            ? "大模型鉴权失败,请检查 fe.kb.api-key"
177
+                            : "大模型鉴权失败";
178
+                    return new BizException(ErrorCode.AI_SERVICE_FAILED, hint);
179
+                }
180
+                return new BizException(ErrorCode.AI_SERVICE_FAILED,
181
+                        errText.length() > 200 ? errText.substring(0, 200) : errText);
182
+            }
183
+        }
184
+        return new BizException(ErrorCode.AI_SERVICE_FAILED, "上游返回 HTTP " + status);
185
+    }
186
+
187
+    private String resolveAgentId(String agentId) {
188
+        if (StringUtils.hasText(agentId)) {
189
+            return agentId.trim();
190
+        }
191
+        return StringUtils.hasText(properties.getDefaultAgentId())
192
+                ? properties.getDefaultAgentId().trim()
193
+                : "default";
194
+    }
195
+
196
+    private void validateRequestBody(byte[] requestBody) {
197
+        if (requestBody == null || requestBody.length == 0) {
198
+            throw new BizException(ErrorCode.BAD_REQUEST, "请求体不能为空");
199
+        }
200
+    }
201
+
202
+    private void ensureEnabled() {
156 203
         if (!properties.isEnabled()) {
157 204
             throw new BizException(ErrorCode.AI_SERVICE_DISABLED);
158 205
         }
159
-        if (!StringUtils.hasText(properties.getApiKey())) {
160
-            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "未配置大模型访问密钥 fe.kb.api-key");
161
-        }
162 206
     }
163 207
 
164
-    private HttpHeaders upstreamAuthHeaders() {
208
+    private HttpHeaders upstreamHeaders(String agentId, boolean requireAgentId) {
165 209
         HttpHeaders headers = new HttpHeaders();
166
-        headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
210
+        properties.applyAuthHeader(headers);
211
+        if (requireAgentId) {
212
+            headers.add(HEADER_AGENT_ID, resolveAgentId(agentId));
213
+        }
167 214
         return headers;
168 215
     }
169 216
 

+ 2 - 5
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KnowledgeBaseClientImpl.java

@@ -82,7 +82,7 @@ public class KnowledgeBaseClientImpl implements KnowledgeBaseClient {
82 82
     private String postMultipart(MultiValueMap<String, Object> body) {
83 83
         String url = properties.baseUrl() + "/api/v1/kb/files";
84 84
         HttpHeaders headers = new HttpHeaders();
85
-        headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
85
+        properties.applyAuthHeader(headers);
86 86
         HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
87 87
         String raw = executeWithRetry("知识库上传", () -> {
88 88
             try {
@@ -114,7 +114,7 @@ public class KnowledgeBaseClientImpl implements KnowledgeBaseClient {
114 114
         }
115 115
         String url = properties.baseUrl() + "/api/v1/kb/files/" + encodePathSegment(kbDocId);
116 116
         HttpHeaders headers = new HttpHeaders();
117
-        headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
117
+        properties.applyAuthHeader(headers);
118 118
         HttpEntity<Void> entity = new HttpEntity<>(headers);
119 119
         executeWithRetry("知识库删除", () -> {
120 120
             try {
@@ -324,8 +324,5 @@ public class KnowledgeBaseClientImpl implements KnowledgeBaseClient {
324 324
         if (!properties.isEnabled()) {
325 325
             throw new BizException(ErrorCode.KB_SERVICE_DISABLED);
326 326
         }
327
-        if (!StringUtils.hasText(properties.getApiKey())) {
328
-            throw new BizException(ErrorCode.KB_SERVICE_FAILED, "未配置知识库访问密钥 fe.kb.api-key");
329
-        }
330 327
     }
331 328
 }

+ 14 - 55
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/AgentChatService.java

@@ -1,6 +1,5 @@
1 1
 package com.huimv.employment.service.agent;
2 2
 
3
-import com.fasterxml.jackson.databind.JsonNode;
4 3
 import com.fasterxml.jackson.databind.ObjectMapper;
5 4
 import com.fasterxml.jackson.databind.node.ArrayNode;
6 5
 import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -10,16 +9,13 @@ import com.huimv.employment.integration.kb.KbApiProperties;
10 9
 import com.huimv.employment.integration.kb.KbOpenAiProxyService;
11 10
 import com.huimv.employment.service.agent.dto.AgentMessageRequest;
12 11
 import com.huimv.employment.service.agent.dto.AgentMessageResponse;
13
-import org.springframework.http.HttpStatus;
14
-import org.springframework.http.ResponseEntity;
15 12
 import org.springframework.stereotype.Service;
16 13
 import org.springframework.util.StringUtils;
17 14
 
18
-import java.nio.charset.StandardCharsets;
19 15
 import java.util.UUID;
20 16
 
21 17
 /**
22
- * 小程序智能体对话:封装 OpenAI Chat Completions 调用。
18
+ * 小程序智能体对话:封装 {@code POST /api/console/chat} 调用。
23 19
  */
24 20
 @Service
25 21
 public class AgentChatService {
@@ -44,71 +40,34 @@ public class AgentChatService {
44 40
                 ? request.getConversationId()
45 41
                 : UUID.randomUUID().toString().replace("-", "");
46 42
 
47
-        byte[] body = buildChatRequest(request.getMessage());
48
-        ResponseEntity<byte[]> upstream = kbOpenAiProxyService.consoleChat(body);
49
-        if (!upstream.getStatusCode().is2xxSuccessful()) {
50
-            throw new BizException(ErrorCode.AI_SERVICE_FAILED, extractUpstreamError(upstream));
51
-        }
43
+        byte[] body = buildChatRequest(userId, conversationId, request.getMessage());
44
+        String reply = kbOpenAiProxyService.collectConsoleChatReply(body, kbApiProperties.getDefaultAgentId());
52 45
 
53
-        String reply = parseReply(upstream.getBody());
54 46
         AgentMessageResponse response = new AgentMessageResponse();
55 47
         response.setReply(reply);
56 48
         response.setConversationId(conversationId);
57 49
         return response;
58 50
     }
59 51
 
60
-    private byte[] buildChatRequest(String userMessage) {
52
+    private byte[] buildChatRequest(Long userId, String sessionId, String userMessage) {
61 53
         try {
62 54
             ObjectNode root = objectMapper.createObjectNode();
63
-            root.put("model", kbApiProperties.getDefaultModel());
64
-            root.put("stream", false);
65 55
 
66
-            ArrayNode messages = root.putArray("messages");
67
-            ObjectNode system = messages.addObject();
68
-            system.put("role", "system");
69
-            system.put("content", SYSTEM_PROMPT);
56
+            ArrayNode input = root.putArray("input");
57
+            ObjectNode userMsg = input.addObject();
58
+            userMsg.put("role", "user");
59
+            ArrayNode userContent = userMsg.putArray("content");
60
+            ObjectNode userText = userContent.addObject();
61
+            userText.put("type", "text");
62
+            userText.put("text", SYSTEM_PROMPT + "\n\n" + userMessage);
70 63
 
71
-            ObjectNode user = messages.addObject();
72
-            user.put("role", "user");
73
-            user.put("content", userMessage);
64
+            root.put("session_id", sessionId);
65
+            root.put("user_id", String.valueOf(userId));
66
+            root.put("channel", "console");
74 67
 
75 68
             return objectMapper.writeValueAsBytes(root);
76 69
         } catch (Exception ex) {
77 70
             throw new BizException(ErrorCode.INTERNAL_ERROR);
78 71
         }
79 72
     }
80
-
81
-    private String parseReply(byte[] body) {
82
-        if (body == null || body.length == 0) {
83
-            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型返回空响应");
84
-        }
85
-        try {
86
-            JsonNode root = objectMapper.readTree(body);
87
-            JsonNode choices = root.get("choices");
88
-            if (choices == null || !choices.isArray() || choices.isEmpty()) {
89
-                throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型响应格式异常");
90
-            }
91
-            JsonNode content = choices.get(0).path("message").path("content");
92
-            if (content.isMissingNode() || !StringUtils.hasText(content.asText())) {
93
-                throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型未返回有效内容");
94
-            }
95
-            return content.asText();
96
-        } catch (BizException ex) {
97
-            throw ex;
98
-        } catch (Exception ex) {
99
-            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "解析大模型响应失败");
100
-        }
101
-    }
102
-
103
-    private String extractUpstreamError(ResponseEntity<byte[]> upstream) {
104
-        byte[] body = upstream.getBody();
105
-        if (body == null || body.length == 0) {
106
-            return "上游返回 HTTP " + upstream.getStatusCode().value();
107
-        }
108
-        String text = new String(body, StandardCharsets.UTF_8);
109
-        if (upstream.getStatusCode() == HttpStatus.UNAUTHORIZED) {
110
-            return "大模型鉴权失败,请检查 fe.kb.api-key";
111
-        }
112
-        return text.length() > 200 ? text.substring(0, 200) : text;
113
-    }
114 73
 }