package com.ruoyi.cms.config; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.cms.domain.chat.ChatRequest; import com.ruoyi.common.utils.StringUtils; import lombok.var; import okhttp3.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; @Component public class ChatClient { // 超时设置(单位:秒) private static final int CONNECT_TIMEOUT = 30; private static final int WRITE_TIMEOUT = 30; private static final int READ_TIMEOUT = 300; // 流式响应不设置读取超时 // 单例 OkHttp 客户端(复用连接池) private static final OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) .build(); private static final String CHAT_ENDPOINT = "chat"; private static final String CHAT_HISTORY = "history"; public static final List TEXT_FILE_EXTENSIONS= Arrays.asList(".txt", ".md", ".html", ".doc", ".docx", ".pdf", ".ppt", ".pptx", ".csv", ".xls", ".xlsx"); public static final List IMAGE_FILE_EXTENSIONS=Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"); private ChatConfig chatConfig; @Autowired public void setChatConfig(ChatConfig chatConfig) { this.chatConfig = chatConfig; } @Value("${spring.profiles.active}") private String env; /** * 发送流式聊天请求 * @param chatRequest 查询请求体 * @param callback 流式响应回调接口 */ public void sendStreamingChat(ChatRequest chatRequest, StreamCallback callback) { String url=chatConfig.getBaseUrl()+chatConfig.getChatUrl(); // 构建请求体 String jsonBody = buildChatRequestBody(chatRequest, CHAT_ENDPOINT); // 构建请求 Request request = null; try { RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"),jsonBody); request = new Request.Builder() .url(url) .addHeader("Content-Type", "application/json") .addHeader("Authorization", "Bearer " + chatConfig.getApiKey()) .post(body).build(); }catch (Exception e){ e.printStackTrace(); callback.onError(new RuntimeException("构建请求失败: " + e.getMessage(), e)); return; } // 发送异步请求 client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { callback.onError(new RuntimeException("请求发送失败: " + e.getMessage(), e)); } @Override public void onResponse(Call call, Response response) throws IOException { try { if (!response.isSuccessful()) { String errorBody = response.body() != null ? response.body().string() : "无错误信息"; String errorMsg = String.format("API 响应错误: 状态码=%d, 错误信息=%s", response.code(), errorBody); System.err.println(errorMsg); // 打印详细错误 callback.onError(new RuntimeException(errorMsg)); return; } // 处理流式响应 ResponseBody body = response.body(); if (body == null) { callback.onError(new RuntimeException("响应体为空")); return; } // 逐行读取 SSE 格式的响应 try (var bufferedSource = body.source()) { while (!bufferedSource.exhausted()) { String chunk = bufferedSource.readUtf8Line(); if (chunk != null && !chunk.trim().isEmpty()) { callback.onData(chunk); } } } // 通知流结束 callback.onComplete(); } catch (Exception e) { String errorMsg = "处理响应失败: " + e.getMessage(); System.err.println(errorMsg); callback.onError(new RuntimeException(errorMsg, e)); } finally { response.close(); } } }); } /** * 构建聊天请求的 JSON 体 */ /** * 构建聊天请求的 JSON 体 * 根据文档 [cite: 162-172] 修改为 Vision API 兼容格式 */ private String buildChatRequestBody(ChatRequest chatRequest, String key) { JSONObject chatObject = new JSONObject(); // 1. 设置会话ID (如果有) if(StringUtils.isNotEmpty(chatRequest.getSessionId())){ chatObject.put("chatId", chatRequest.getSessionId()); } if("chat".equals(key)){ // 基础参数设置 chatObject.put("stream", true); chatObject.put("model", chatConfig.getModel()); // 需确保为 "qd-job-turbo" chatObject.put("user", chatRequest.getSessionId()); // 2. 获取历史消息列表,如果为空则初始化 JSONArray messages = chatRequest.getMessages(); if (messages == null) { messages = new JSONArray(); } // 3. 构建当前用户的多模态消息内容 (Multimodal Content) [cite: 162] JSONArray contentArray = new JSONArray(); // 3.1 添加文本内容 [cite: 166, 172] if(StringUtils.isNotEmpty(chatRequest.getData())){ JSONObject textPart = new JSONObject(); textPart.put("type", "text"); textPart.put("text", chatRequest.getData()); contentArray.add(textPart); } // 3.2 添加文件内容 (图片、PDF、Excel等) [cite: 174, 180, 232] // 文档说明:image_url 字段兼容 PDF, Excel, PPT 等所有 OCR 支持的文件 if(!CollectionUtils.isEmpty(chatRequest.getFileUrl())){ for(String url : chatRequest.getFileUrl()){ String finalUrl = ""; if(Objects.equals(env, "pro")){ finalUrl = url.replace("https://fw.rc.qingdao.gov.cn/rgpp-api/api/ng", "http://10.213.6.207:19010"); }else { finalUrl = url; } // 处理内网/外网地址映射 (保留你原有的逻辑) JSONObject filePart = new JSONObject(); filePart.put("type", "image_url"); // 固定为 image_url [cite: 174] JSONObject imageUrlObj = new JSONObject(); imageUrlObj.put("url", finalUrl); // 文件地址 [cite: 172] filePart.put("image_url", imageUrlObj); contentArray.add(filePart); } } // 4. 将当前消息封装为 User Message 对象并加入消息列表 [cite: 151, 163] // 只有当有内容(文本或文件)时才添加 if (!contentArray.isEmpty()) { JSONObject currentUserMessage = new JSONObject(); currentUserMessage.put("role", "user"); currentUserMessage.put("content", contentArray); // content 为数组格式 messages.add(currentUserMessage); } // 5. 将完整的消息列表放入请求体 [cite: 135] chatObject.put("messages", messages); } else { // 非 chat 场景的逻辑保留 chatObject.put("appId", chatConfig.getAppId()); } return chatObject.toJSONString(); } /** * 简单的 JSON 转义处理(防止特殊字符破坏 JSON 格式) */ private String escapeJson(String value) { if (value == null) return ""; return value .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\b", "\\b") .replace("\f", "\\f") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t"); } /** * 发送聊天请求并返回完整JSON响应 * @param chatRequest 用户输入的查询内容 * @return 完整的JSON响应字符串 * @throws IOException 网络请求异常 */ public String sendChatGuest(ChatRequest chatRequest) throws IOException { String url=chatConfig.getBaseUrl()+chatConfig.getGuestUrl(); JSONArray array = chatRequest.getMessages(); if(array==null||array.isEmpty()||array.size()==0){ array = new JSONArray(); } JSONObject contentObject = new JSONObject(); contentObject.put("content","你是一个岗位招聘专家,请根据用户的问题生成用户下一步想要提出的问题。需要以用户的口吻进行生成。结合上下文中用户提出的问题以及助手回复的答案,需要猜测用户更进一步的需求,例如期望的薪资,期望的工作地点,掌握的技能。生成的问题举例:有没有薪资在9000以上的工作?我的学历是本科。我希望找国企。注意不仅限于这些,还要根据上下文。其次所有的问题应该限定在喀什。并且只生成3到4个。"); contentObject.put("role","system"); array.add(contentObject); JSONObject jsonBody = new JSONObject(); jsonBody.put("stream",false); jsonBody.put("model",chatConfig.getModel()); jsonBody.put("messages",array); // 构建请求(使用非流式响应模式) RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), String.valueOf(jsonBody)); Request request = new Request.Builder() .url(url) .addHeader("Content-Type", "application/json") .addHeader("Authorization", "Bearer " + chatConfig.getApiKey()) .post(requestBody).build(); // 发送同步请求 try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { String errorBody = response.body() != null ? response.body().string() : "无错误信息"; String errorMsg = String.format("API 响应错误: 状态码=%d, 错误信息=%s", response.code(), errorBody); throw new IOException(errorMsg); } ResponseBody body = response.body(); if (body == null) { throw new IOException("响应体为空"); } JSONObject object = JSONObject.parseObject(body.string()); String choices = object.getString("choices"); if (choices != null && !choices.trim().isEmpty()) { JSONArray jsonArray = JSONArray.parseArray(choices); object = JSONObject.parseObject(jsonArray.getString(0)); object = object.getJSONObject("message"); String content = object.getString("content");// 消息内容 return content; } return body.string(); } } /** * 流式响应回调接口 */ public interface StreamCallback { /** * 接收分片数据 * @param chunk SSE 格式的分片数据 */ void onData(String chunk); /** * 响应结束 */ void onComplete(); /** * 发生错误 * @param e 异常信息 */ void onError(Throwable e); } }