diff --git a/app/src/main/java/com/tinyengine/it/config/filter/WebConfig.java b/app/src/main/java/com/tinyengine/it/config/filter/WebConfig.java index 839421f0..f947c0b9 100644 --- a/app/src/main/java/com/tinyengine/it/config/filter/WebConfig.java +++ b/app/src/main/java/com/tinyengine/it/config/filter/WebConfig.java @@ -11,9 +11,11 @@ package com.tinyengine.it.config.filter; +import com.tinyengine.it.common.converter.StreamingResponseBodyConverter; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @@ -27,6 +29,17 @@ public class WebConfig implements WebMvcConfigurer { @Value("${cors.allowed-origins}") private String allowedOrigins; + private final StreamingResponseBodyConverter streamingResponseBodyConverter; + + public WebConfig(StreamingResponseBodyConverter streamingResponseBodyConverter) { + this.streamingResponseBodyConverter = streamingResponseBodyConverter; + } + + @Override + public void extendMessageConverters(List> converters) { + // 添加自定义的 StreamingResponseBody 转换器 + converters.add(streamingResponseBodyConverter); + } @Bean public CorsFilter corsFilter() { // 跨域配置地址 diff --git a/base/src/main/java/com/tinyengine/it/common/converter/StreamingResponseBodyConverter.java b/base/src/main/java/com/tinyengine/it/common/converter/StreamingResponseBodyConverter.java new file mode 100644 index 00000000..2bbf1ae4 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/common/converter/StreamingResponseBodyConverter.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +package com.tinyengine.it.common.converter; + + +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * The type StreamingResponseBodyConverter. + * + * @since 2025-08-06 + */ +@Component +public class StreamingResponseBodyConverter extends AbstractHttpMessageConverter { + + public StreamingResponseBodyConverter() { + super(MediaType.TEXT_EVENT_STREAM); + } + + @Override + protected boolean supports(Class clazz) { + return StreamingResponseBody.class.isAssignableFrom(clazz); + } + + @Override + protected StreamingResponseBody readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + throw new UnsupportedOperationException("Streaming response body does not support input."); + } + + @Override + protected void writeInternal(StreamingResponseBody responseBody, HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + OutputStream outputStream = outputMessage.getBody(); + responseBody.writeTo(outputStream); // 使用 StreamingResponseBody 的 writeTo 方法 + } +} \ No newline at end of file diff --git a/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java b/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java index 1f5c5113..56d10b06 100644 --- a/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java +++ b/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java @@ -250,7 +250,12 @@ public enum ExceptionEnum implements IBaseError { /** * Cm 325 exception enum. */ - CM325("CM325", "文件校验失败"); + CM325("CM325", "文件校验失败"), + + /** + * Cm 326 exception enum. + */ + CM326("CM326", "Failed to write stream data"); /** * 错误码 diff --git a/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java b/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java new file mode 100644 index 00000000..0de5a4fd --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +package com.tinyengine.it.config; + +import lombok.Data; +import org.springframework.context.annotation.Configuration; + +/** + * The type Open AI config. + * + * @since 2025-08-06 + */ +@Data +@Configuration +public class OpenAIConfig { + private String apiKey = "your-api-key"; + private String baseUrl = "https://api.deepseek.com/chat/completions"; + private String defaultModel = "deepseek-chat"; + private int timeoutSeconds = 300; +} diff --git a/base/src/main/java/com/tinyengine/it/controller/AiChatController.java b/base/src/main/java/com/tinyengine/it/controller/AiChatController.java index 697ef7ab..69a066fb 100644 --- a/base/src/main/java/com/tinyengine/it/controller/AiChatController.java +++ b/base/src/main/java/com/tinyengine/it/controller/AiChatController.java @@ -15,8 +15,10 @@ import com.tinyengine.it.common.base.Result; import com.tinyengine.it.common.log.SystemControllerLog; import com.tinyengine.it.model.dto.AiParam; +import com.tinyengine.it.model.dto.ChatRequest; import com.tinyengine.it.service.app.AiChatService; +import com.tinyengine.it.service.app.v1.AiChatV1Service; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -25,11 +27,15 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.util.Map; @@ -49,6 +55,12 @@ public class AiChatController { @Autowired private AiChatService aiChatService; + /** + * The Ai chat v1 service. + */ + @Autowired + private AiChatV1Service aiChatV1Service; + /** * AI api * @@ -65,4 +77,34 @@ public class AiChatController { public Result> aiChat(@RequestBody AiParam aiParam) { return aiChatService.getAnswerFromAi(aiParam); } + + /** + * AI api v1 + * + * @param request the AI param + * @return ai回答信息 result + */ + @Operation(summary = "获取ai回答信息", description = "获取ai回答信息", parameters = { + @Parameter(name = "ChatRequest", description = "入参对象")}, responses = { + @ApiResponse(responseCode = "200", description = "返回信息", + content = @Content(mediaType = "application/json", schema = @Schema())), + @ApiResponse(responseCode = "400", description = "请求失败")}) + @SystemControllerLog(description = "AI api v1") + @PostMapping("/chat/completions") + public ResponseEntity chat(@RequestBody ChatRequest request) { + try { + Object response = aiChatV1Service.chatCompletion(request); + + if (request.isStream()) { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body((StreamingResponseBody) response); + } else { + return ResponseEntity.ok(response); + } + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(e.getMessage()); + } + } } diff --git a/base/src/main/java/com/tinyengine/it/model/dto/ChatRequest.java b/base/src/main/java/com/tinyengine/it/model/dto/ChatRequest.java new file mode 100644 index 00000000..79711779 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/model/dto/ChatRequest.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +package com.tinyengine.it.model.dto; + +import lombok.Data; + +import java.util.List; + +/** + * ChatRequest dto + * + * @since 2025-08-06 + */ +@Data +public class ChatRequest { + private String model; + private String apiKey; + private String baseUrl; + private List messages; + private Double temperature = 0.7; + private boolean stream = false; // 流式开关 +} diff --git a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java new file mode 100644 index 00000000..9c3bd87d --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +package com.tinyengine.it.service.app.impl.v1; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.tinyengine.it.common.exception.ExceptionEnum; +import com.tinyengine.it.common.exception.ServiceException; +import com.tinyengine.it.common.utils.JsonUtils; +import com.tinyengine.it.config.OpenAIConfig; +import com.tinyengine.it.model.dto.ChatRequest; +import com.tinyengine.it.service.app.v1.AiChatV1Service; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +/** + * The type AiChat v1 service. + * + * @since 2025-08-06 + */ +@Service +@Slf4j +public class AiChatV1ServiceImpl implements AiChatV1Service { + private final OpenAIConfig config = new OpenAIConfig(); + private HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) + .build(); + + /** + * chatCompletion. + * + * @param request the request + * @return Object the Object + */ + @Override + public Object chatCompletion(ChatRequest request) throws Exception { + String requestBody = buildRequestBody(request); + String apiKey = request.getApiKey() != null ? request.getApiKey() : config.getApiKey(); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(request.getBaseUrl() != null ? request.getBaseUrl() : config.getBaseUrl())) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)); + + if (request.isStream()) { + requestBuilder.header("Accept", "text/event-stream"); + return processStreamResponse(requestBuilder); + } else { + return processStandardResponse(requestBuilder); + } + } + + private String buildRequestBody(ChatRequest request) throws JsonProcessingException { + Map body = new HashMap<>(); + body.put("model", request.getModel() != null ? request.getModel() : config.getDefaultModel()); + body.put("messages", request.getMessages()); + body.put("temperature", request.getTemperature()); + body.put("stream", request.isStream()); + + return JsonUtils.encode(body); + } + + private JsonNode processStandardResponse(HttpRequest.Builder requestBuilder) + throws Exception { + HttpResponse response = httpClient.send( + requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + return JsonUtils.MAPPER.readTree(response.body()); + } + + private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestBuilder) { + return outputStream -> { + try { + HttpResponse> response = httpClient.send( + requestBuilder.build(), HttpResponse.BodyHandlers.ofLines()); + try (Stream lines = response.body()) { + lines.filter(line -> !line.isEmpty()) + .forEach(line -> { + try { + if (!line.startsWith("data:")) { + line = "data: " + line; + } + if (!line.endsWith("\n\n")) { + line = line + "\n\n"; + } + outputStream.write(line.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } catch (IOException e) { + throw new ServiceException(ExceptionEnum.CM326.getResultCode(), + ExceptionEnum.CM326.getResultMsg()); + } + }); + } + } catch (Exception e) { + try { + String errorEvent = "data: " + + JsonUtils.encode(Map.of("error", e.getMessage())) + "\n\n"; + outputStream.write(errorEvent.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } catch (IOException ioException) { + throw new ServiceException(ExceptionEnum.CM326.getResultCode(), ExceptionEnum.CM326.getResultMsg()); + } + } finally { + try { + outputStream.close(); + } catch (IOException e) { + // 忽略关闭异常 + } + } + }; + } +} diff --git a/base/src/main/java/com/tinyengine/it/service/app/v1/AiChatV1Service.java b/base/src/main/java/com/tinyengine/it/service/app/v1/AiChatV1Service.java new file mode 100644 index 00000000..d2bda179 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/service/app/v1/AiChatV1Service.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +package com.tinyengine.it.service.app.v1; + +import com.tinyengine.it.model.dto.ChatRequest; + +/** + * The interface AIChat v 1 service. + * + * @since 2025-08-06 + */ +public interface AiChatV1Service { + /** + * chatCompletion. + * + * @param request the request + * @return Object the Object + */ + public Object chatCompletion(ChatRequest request) throws Exception; +}