feat: AI 场景路由、ASR 服务及前后端全链路同步

- 新增 AI 场景路由控制器和管理接口
- 新增 ASR 语音识别服务及前后端集成
- 同步 AI Runtime 客户端到 Web/小程序/Life-Script
- 完善 AI 配置测试修复和管理后台路由配置
- 新增数据库迁移脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 13:25:21 +08:00
parent d77090aa5e
commit 89fc42819d
72 changed files with 4584 additions and 383 deletions
@@ -0,0 +1,171 @@
package com.emotion.controller;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.common.Result;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiCallLog;
import com.emotion.entity.AiEndpointConfig;
import com.emotion.entity.AiProvider;
import com.emotion.entity.AiSceneBinding;
import com.emotion.service.AiCallLogService;
import com.emotion.service.AiEndpointConfigService;
import com.emotion.service.AiProviderService;
import com.emotion.service.AiRuntimeService;
import com.emotion.service.AiSceneBindingService;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Slf4j
@RestController
@RequestMapping("/ai")
public class AiRoutingController {
private final AiProviderService providerService;
private final AiEndpointConfigService endpointConfigService;
private final AiSceneBindingService sceneBindingService;
private final AiCallLogService callLogService;
private final AiRuntimeService runtimeService;
public AiRoutingController(AiProviderService providerService,
AiEndpointConfigService endpointConfigService,
AiSceneBindingService sceneBindingService,
AiCallLogService callLogService,
AiRuntimeService runtimeService) {
this.providerService = providerService;
this.endpointConfigService = endpointConfigService;
this.sceneBindingService = sceneBindingService;
this.callLogService = callLogService;
this.runtimeService = runtimeService;
}
@GetMapping("/providers")
public Result<List<AiProvider>> providers() {
return Result.success(providerService.listVisible());
}
@PostMapping("/providers")
public Result<AiProvider> createProvider(@RequestBody AiProvider provider) {
return Result.success(providerService.saveProvider(provider));
}
@PutMapping("/providers")
public Result<AiProvider> updateProvider(@RequestBody AiProvider provider) {
return Result.success(providerService.updateProvider(provider));
}
@DeleteMapping("/providers")
public Result<Void> deleteProvider(@RequestParam String id) {
providerService.removeById(id);
return Result.success();
}
@GetMapping("/endpoints")
public Result<List<AiEndpointConfig>> endpoints() {
return Result.success(endpointConfigService.listVisible());
}
@PostMapping("/endpoints")
public Result<AiEndpointConfig> createEndpoint(@RequestBody AiEndpointConfig endpoint) {
return Result.success(endpointConfigService.saveEndpoint(endpoint));
}
@PutMapping("/endpoints")
public Result<AiEndpointConfig> updateEndpoint(@RequestBody AiEndpointConfig endpoint) {
return Result.success(endpointConfigService.updateEndpoint(endpoint));
}
@DeleteMapping("/endpoints")
public Result<Void> deleteEndpoint(@RequestParam String id) {
endpointConfigService.removeById(id);
return Result.success();
}
@GetMapping("/scenes")
public Result<List<AiSceneBinding>> scenes() {
return Result.success(sceneBindingService.listVisible());
}
@PostMapping("/scenes")
public Result<AiSceneBinding> createScene(@RequestBody AiSceneBinding scene) {
if (scene.getIsEnabled() == null) {
scene.setIsEnabled(1);
}
if (scene.getRequiredStream() == null) {
scene.setRequiredStream(1);
}
sceneBindingService.save(scene);
return Result.success(scene);
}
@PutMapping("/scenes")
public Result<AiSceneBinding> updateScene(@RequestBody AiSceneBinding scene) {
sceneBindingService.updateById(scene);
return Result.success(sceneBindingService.getById(scene.getId()));
}
@DeleteMapping("/scenes")
public Result<Void> deleteScene(@RequestParam String id) {
sceneBindingService.removeById(id);
return Result.success();
}
@GetMapping("/call-logs")
public Result<List<AiCallLog>> callLogs(@RequestParam(required = false) Integer limit) {
return Result.success(callLogService.latest(limit));
}
@PostMapping("/runtime/test")
public Result<AiRuntimeTestResponse> runtimeTest(@RequestBody JSONObject payload) {
AiRuntimeRequest request = withCurrentUser(AiRuntimeRequest.fromPayload(payload));
return Result.success(runtimeService.test(request));
}
@PostMapping("/runtime/stream")
public SseEmitter runtimeStream(@RequestBody JSONObject payload) {
AiRuntimeRequest request = withCurrentUser(AiRuntimeRequest.fromPayload(payload));
SseEmitter emitter = new SseEmitter(0L);
CompletableFuture.runAsync(() -> {
runtimeService.invokeStream(request, event -> sendEvent(emitter, event));
emitter.complete();
}).exceptionally(error -> {
sendEvent(emitter, AiStreamEvent.error("AI_STREAM_INTERRUPTED", error.getMessage()));
emitter.completeWithError(error);
return null;
});
return emitter;
}
private AiRuntimeRequest withCurrentUser(AiRuntimeRequest request) {
request.setUserId(UserContextHolder.getCurrentUserId());
request.setUserName(UserContextHolder.getCurrentUsername());
request.setUserType(UserContextHolder.getCurrentUserType());
request.setRequestId(UserContextHolder.getRequestId());
return request;
}
private void sendEvent(SseEmitter emitter, AiStreamEvent event) {
try {
emitter.send(SseEmitter.event()
.name(event.getType())
.data(event));
} catch (IOException e) {
log.warn("AI stream client disconnected: {}", e.getMessage());
throw new IllegalStateException("AI_STREAM_CLIENT_DISCONNECTED", e);
}
}
}
@@ -0,0 +1,36 @@
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.response.asr.AsrTranscribeResponse;
import com.emotion.service.AsrService;
import com.emotion.util.UserContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/asr")
public class AsrController {
private final AsrService asrService;
public AsrController(AsrService asrService) {
this.asrService = asrService;
}
@PostMapping("/transcribe")
public Result<AsrTranscribeResponse> transcribe(@RequestPart("file") MultipartFile file) {
if (UserContextHolder.getCurrentUserId() == null) {
return Result.unauthorized();
}
try {
return Result.success(asrService.transcribe(file));
} catch (IllegalArgumentException e) {
return Result.badRequest(e.getMessage());
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,50 @@
package com.emotion.dto.request.ai;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import org.springframework.util.StringUtils;
import java.util.Set;
@Data
public class AiRuntimeRequest {
private static final Set<String> RESERVED_KEYS = Set.of("sceneCode", "scene", "inputs", "userId", "userName", "username", "userType", "requestId");
private String sceneCode;
private String userId;
private String userName;
private String userType;
private String requestId;
private JSONObject inputs = new JSONObject();
public static AiRuntimeRequest fromPayload(JSONObject payload) {
AiRuntimeRequest request = new AiRuntimeRequest();
if (payload == null) {
return request;
}
String sceneCode = payload.getString("sceneCode");
if (!StringUtils.hasText(sceneCode)) {
sceneCode = payload.getString("scene");
}
request.setSceneCode(sceneCode);
JSONObject inputs = payload.getJSONObject("inputs");
if (inputs == null) {
inputs = new JSONObject();
JSONObject runtimeInputs = inputs;
payload.forEach((key, value) -> {
if (!RESERVED_KEYS.contains(key)) {
runtimeInputs.put(key, value);
}
});
}
request.setInputs(inputs);
return request;
}
}
@@ -0,0 +1,23 @@
package com.emotion.dto.response.ai;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiRuntimeTestResponse {
private String sceneCode;
private String status;
private String output;
private Long durationMs;
private Integer streamChunks;
private String errorCode;
private String errorMessage;
}
@@ -0,0 +1,64 @@
package com.emotion.dto.response.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiStreamEvent {
private String type;
private String content;
private String code;
private String message;
private Integer seq;
@Builder.Default
private Long timestamp = System.currentTimeMillis();
@Builder.Default
private Map<String, Object> metadata = new HashMap<>();
public static AiStreamEvent start(String sceneCode) {
return AiStreamEvent.builder()
.type("start")
.message("stream started")
.metadata(Map.of("sceneCode", sceneCode))
.build();
}
public static AiStreamEvent delta(String content, int seq) {
return AiStreamEvent.builder()
.type("delta")
.content(content)
.seq(seq)
.build();
}
public static AiStreamEvent error(String code, String message) {
return AiStreamEvent.builder()
.type("error")
.code(code)
.message(message)
.build();
}
public static AiStreamEvent done(Map<String, Object> metadata) {
return AiStreamEvent.builder()
.type("done")
.message("stream completed")
.metadata(metadata == null ? Map.of() : metadata)
.build();
}
}
@@ -0,0 +1,16 @@
package com.emotion.dto.response.asr;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AsrTranscribeResponse {
private String text;
private String language;
private Long durationMs;
private String engine;
private String model;
private String errorMessage;
}
@@ -0,0 +1,58 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_ai_call_log")
public class AiCallLog extends BaseEntity {
@TableField("scene_code")
private String sceneCode;
@TableField("provider_code")
private String providerCode;
@TableField("endpoint_code")
private String endpointCode;
@TableField("user_id")
private String userId;
@TableField("request_id")
private String requestId;
@TableField("status")
private String status;
@TableField("input_text")
private String inputText;
@TableField("output_text")
private String outputText;
@TableField("error_code")
private String errorCode;
@TableField("error_message")
private String errorMessage;
@TableField("first_token_ms")
private Long firstTokenMs;
@TableField("duration_ms")
private Long durationMs;
@TableField("stream_chunks")
private Integer streamChunks;
}
@@ -0,0 +1,67 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_ai_endpoint_config")
public class AiEndpointConfig extends BaseEntity {
@TableField("endpoint_code")
private String endpointCode;
@TableField("endpoint_name")
private String endpointName;
@TableField("provider_id")
private String providerId;
@TableField("endpoint_type")
private String endpointType;
@TableField("api_path")
private String apiPath;
@TableField("workflow_id")
private String workflowId;
@TableField("bot_id")
private String botId;
@TableField("model_name")
private String modelName;
@TableField("response_mode")
private String responseMode;
@TableField("request_template")
private String requestTemplate;
@TableField("default_inputs")
private String defaultInputs;
@TableField("custom_headers")
private String customHeaders;
@TableField("timeout_ms")
private Integer timeoutMs;
@TableField("support_stream")
private Integer supportStream;
@TableField("is_enabled")
private Integer isEnabled;
@TableField("description")
private String description;
}
@@ -0,0 +1,49 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_ai_provider")
public class AiProvider extends BaseEntity {
@TableField("provider_code")
private String providerCode;
@TableField("provider_name")
private String providerName;
@TableField("provider_type")
private String providerType;
@TableField("base_url")
private String baseUrl;
@TableField("api_key")
private String apiKey;
@TableField("auth_type")
private String authType;
@TableField("default_headers")
private String defaultHeaders;
@TableField("timeout_ms")
private Integer timeoutMs;
@TableField("is_enabled")
private Integer isEnabled;
@TableField("description")
private String description;
}
@@ -0,0 +1,49 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_ai_scene_binding")
public class AiSceneBinding extends BaseEntity {
@TableField("scene_code")
private String sceneCode;
@TableField("scene_name")
private String sceneName;
@TableField("endpoint_id")
private String endpointId;
@TableField("input_schema")
private String inputSchema;
@TableField("prompt_template")
private String promptTemplate;
@TableField("required_stream")
private Integer requiredStream;
@TableField("priority")
private Integer priority;
@TableField("is_enabled")
private Integer isEnabled;
@TableField("version")
private String version;
@TableField("description")
private String description;
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.AiCallLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiCallLogMapper extends BaseMapper<AiCallLog> {
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.AiEndpointConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiEndpointConfigMapper extends BaseMapper<AiEndpointConfig> {
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.AiProvider;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiProviderMapper extends BaseMapper<AiProvider> {
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.AiSceneBinding;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiSceneBindingMapper extends BaseMapper<AiSceneBinding> {
}
@@ -0,0 +1,11 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.entity.AiCallLog;
import java.util.List;
public interface AiCallLogService extends IService<AiCallLog> {
List<AiCallLog> latest(Integer limit);
}
@@ -0,0 +1,17 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.entity.AiEndpointConfig;
import java.util.List;
public interface AiEndpointConfigService extends IService<AiEndpointConfig> {
List<AiEndpointConfig> listVisible();
AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint);
AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint);
AiEndpointConfig getEnabledById(String id);
}
@@ -0,0 +1,17 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.entity.AiProvider;
import java.util.List;
public interface AiProviderService extends IService<AiProvider> {
List<AiProvider> listVisible();
AiProvider saveProvider(AiProvider provider);
AiProvider updateProvider(AiProvider provider);
AiProvider getEnabledById(String id);
}
@@ -0,0 +1,14 @@
package com.emotion.service;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent;
import java.util.function.Consumer;
public interface AiRuntimeService {
void invokeStream(AiRuntimeRequest request, Consumer<AiStreamEvent> consumer);
AiRuntimeTestResponse test(AiRuntimeRequest request);
}
@@ -0,0 +1,13 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.entity.AiSceneBinding;
import java.util.List;
public interface AiSceneBindingService extends IService<AiSceneBinding> {
List<AiSceneBinding> listVisible();
AiSceneBinding resolveScene(String sceneCode);
}
@@ -0,0 +1,9 @@
package com.emotion.service;
import com.emotion.dto.response.asr.AsrTranscribeResponse;
import org.springframework.web.multipart.MultipartFile;
public interface AsrService {
AsrTranscribeResponse transcribe(MultipartFile file);
}
@@ -0,0 +1,15 @@
package com.emotion.service.ai;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiEndpointConfig;
import com.emotion.entity.AiProvider;
import java.util.function.Consumer;
public interface AiProviderAdapter {
boolean supports(String providerType);
void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer);
}
@@ -0,0 +1,50 @@
package com.emotion.service.ai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
@Component
public class AiTemplateRenderer {
public Map<String, Object> mergeInputs(String defaultInputs, Map<String, Object> runtimeInputs) {
Map<String, Object> inputs = new HashMap<>();
if (StringUtils.hasText(defaultInputs)) {
try {
inputs.putAll(JSON.parseObject(defaultInputs));
} catch (Exception ignored) {
inputs.put("default_input", defaultInputs);
}
}
if (runtimeInputs != null) {
inputs.putAll(runtimeInputs);
}
return inputs;
}
public JSONObject renderObject(String template, Map<String, Object> inputs) {
if (!StringUtils.hasText(template)) {
return new JSONObject();
}
String rendered = template;
for (Map.Entry<String, Object> entry : inputs.entrySet()) {
String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
rendered = rendered.replace("{{" + entry.getKey() + "}}", value);
}
return JSON.parseObject(rendered);
}
public String firstText(Map<String, Object> inputs) {
for (String key : new String[]{"prompt", "message", "query", "text", "input"}) {
Object value = inputs.get(key);
if (value != null && StringUtils.hasText(String.valueOf(value))) {
return String.valueOf(value);
}
}
return "";
}
}
@@ -0,0 +1,132 @@
package com.emotion.service.ai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiEndpointConfig;
import com.emotion.entity.AiProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.function.Consumer;
@Component
public class CozeProviderAdapter implements AiProviderAdapter {
private final RestTemplate restTemplate;
private final AiTemplateRenderer templateRenderer;
private final ProviderHttpSupport httpSupport;
public CozeProviderAdapter(RestTemplate restTemplate, AiTemplateRenderer templateRenderer, ProviderHttpSupport httpSupport) {
this.restTemplate = restTemplate;
this.templateRenderer = templateRenderer;
this.httpSupport = httpSupport;
}
@Override
public boolean supports(String providerType) {
return "coze".equalsIgnoreCase(providerType);
}
@Override
public void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer) {
Map<String, Object> inputs = templateRenderer.mergeInputs(endpoint.getDefaultInputs(), request.getInputs());
JSONObject body = buildRequestBody(endpoint, request, inputs);
String path = StringUtils.hasText(endpoint.getApiPath()) ? endpoint.getApiPath() : defaultPath(endpoint);
String url = httpSupport.joinUrl(provider.getBaseUrl(), path);
ProviderHttpSupport.Counter counter = new ProviderHttpSupport.Counter();
restTemplate.execute(url, HttpMethod.POST, clientRequest -> {
HttpHeaders headers = clientRequest.getHeaders();
httpSupport.applyHeaders(headers, provider, endpoint);
clientRequest.getBody().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
}, response -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data:")) {
httpSupport.emitSseData(line.substring(5).trim(), consumer, this::extractCozeDelta, counter);
}
}
}
return null;
});
if (counter.get() == 0) {
throw new IllegalStateException("AI_STREAM_NO_DELTA");
}
}
private JSONObject buildRequestBody(AiEndpointConfig endpoint, AiRuntimeRequest request, Map<String, Object> inputs) {
if (StringUtils.hasText(endpoint.getRequestTemplate())) {
JSONObject rendered = templateRenderer.renderObject(endpoint.getRequestTemplate(), inputs);
rendered.putIfAbsent("stream", true);
return rendered;
}
JSONObject body = new JSONObject();
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
body.put("workflow_id", endpoint.getWorkflowId());
body.put("parameters", inputs);
body.put("is_async", false);
return body;
}
body.put("bot_id", endpoint.getBotId());
body.put("user_id", StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous");
body.put("stream", true);
body.put("auto_save_history", false);
JSONArray messages = new JSONArray();
JSONObject message = new JSONObject();
message.put("role", "user");
message.put("content_type", "text");
message.put("content", templateRenderer.firstText(inputs));
messages.add(message);
body.put("additional_messages", messages);
return body;
}
private String defaultPath(AiEndpointConfig endpoint) {
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
return "/v1/workflow/stream_run";
}
return "/v3/chat";
}
private String extractCozeDelta(JSONObject json) {
String type = json.getString("type");
String content = json.getString("content");
if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) {
return content;
}
JSONObject message = json.getJSONObject("message");
if (message != null && StringUtils.hasText(message.getString("content"))) {
return message.getString("content");
}
JSONObject data = json.getJSONObject("data");
if (data != null) {
String output = data.getString("output");
if (StringUtils.hasText(output)) {
return output;
}
String answer = data.getString("answer");
if (StringUtils.hasText(answer)) {
return answer;
}
String dataContent = data.getString("content");
if (StringUtils.hasText(dataContent)) {
return dataContent;
}
}
return null;
}
}
@@ -0,0 +1,121 @@
package com.emotion.service.ai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiEndpointConfig;
import com.emotion.entity.AiProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.function.Consumer;
@Component
public class DifyProviderAdapter implements AiProviderAdapter {
private final RestTemplate restTemplate;
private final AiTemplateRenderer templateRenderer;
private final ProviderHttpSupport httpSupport;
public DifyProviderAdapter(RestTemplate restTemplate, AiTemplateRenderer templateRenderer, ProviderHttpSupport httpSupport) {
this.restTemplate = restTemplate;
this.templateRenderer = templateRenderer;
this.httpSupport = httpSupport;
}
@Override
public boolean supports(String providerType) {
return "dify".equalsIgnoreCase(providerType);
}
@Override
public void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer) {
Map<String, Object> inputs = templateRenderer.mergeInputs(endpoint.getDefaultInputs(), request.getInputs());
JSONObject body = buildRequestBody(endpoint, request, inputs);
String path = StringUtils.hasText(endpoint.getApiPath()) ? endpoint.getApiPath() : defaultPath(endpoint);
String url = httpSupport.joinUrl(provider.getBaseUrl(), path);
ProviderHttpSupport.Counter counter = new ProviderHttpSupport.Counter();
restTemplate.execute(url, HttpMethod.POST, clientRequest -> {
HttpHeaders headers = clientRequest.getHeaders();
httpSupport.applyHeaders(headers, provider, endpoint);
clientRequest.getBody().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
}, response -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data:")) {
httpSupport.emitSseData(line.substring(5).trim(), consumer, this::extractDifyDelta, counter);
}
}
}
return null;
});
if (counter.get() == 0) {
throw new IllegalStateException("AI_STREAM_NO_DELTA");
}
}
private JSONObject buildRequestBody(AiEndpointConfig endpoint, AiRuntimeRequest request, Map<String, Object> inputs) {
if (StringUtils.hasText(endpoint.getRequestTemplate())) {
JSONObject rendered = templateRenderer.renderObject(endpoint.getRequestTemplate(), inputs);
rendered.putIfAbsent("response_mode", "streaming");
rendered.putIfAbsent("user", user(request));
return rendered;
}
JSONObject body = new JSONObject();
body.put("response_mode", "streaming");
body.put("user", user(request));
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
body.put("query", templateRenderer.firstText(inputs));
body.put("inputs", inputs);
} else {
body.put("inputs", inputs);
}
return body;
}
private String defaultPath(AiEndpointConfig endpoint) {
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
return "/chat-messages";
}
return "/workflows/run";
}
private String user(AiRuntimeRequest request) {
return StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous";
}
private String extractDifyDelta(JSONObject json) {
String event = json.getString("event");
if ("message".equals(event) || "agent_message".equals(event)) {
return json.getString("answer");
}
if ("text_chunk".equals(event)) {
JSONObject data = json.getJSONObject("data");
return data == null ? null : data.getString("text");
}
if ("workflow_finished".equals(event)) {
JSONObject data = json.getJSONObject("data");
JSONObject outputs = data == null ? null : data.getJSONObject("outputs");
if (outputs != null) {
Object text = outputs.get("text");
if (text == null) {
text = outputs.get("answer");
}
return text == null ? null : String.valueOf(text);
}
}
return null;
}
}
@@ -0,0 +1,88 @@
package com.emotion.service.ai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiEndpointConfig;
import com.emotion.entity.AiProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.function.Consumer;
@Component
public class ProviderHttpSupport {
public String joinUrl(String baseUrl, String path) {
String base = StringUtils.hasText(baseUrl) ? baseUrl.trim() : "";
String suffix = StringUtils.hasText(path) ? path.trim() : "";
if (!base.endsWith("/") && !suffix.startsWith("/")) {
return base + "/" + suffix;
}
if (base.endsWith("/") && suffix.startsWith("/")) {
return base + suffix.substring(1);
}
return base + suffix;
}
public void applyHeaders(HttpHeaders headers, AiProvider provider, AiEndpointConfig endpoint) {
headers.setContentType(MediaType.APPLICATION_JSON);
if (StringUtils.hasText(provider.getApiKey())) {
headers.setBearerAuth(provider.getApiKey());
}
applyJsonHeaders(headers, provider.getDefaultHeaders());
applyJsonHeaders(headers, endpoint.getCustomHeaders());
}
public void emitSseData(String data, Consumer<AiStreamEvent> consumer, DeltaExtractor extractor, Counter counter) {
if (!StringUtils.hasText(data) || "[DONE]".equals(data.trim())) {
return;
}
try {
JSONObject json = JSON.parseObject(data);
String delta = extractor.extract(json);
if (StringUtils.hasText(delta)) {
counter.increment();
consumer.accept(AiStreamEvent.delta(delta, counter.get()));
}
} catch (Exception ignored) {
counter.increment();
consumer.accept(AiStreamEvent.delta(data, counter.get()));
}
}
private void applyJsonHeaders(HttpHeaders headers, String jsonText) {
if (!StringUtils.hasText(jsonText)) {
return;
}
try {
JSONObject json = JSON.parseObject(jsonText);
for (Map.Entry<String, Object> entry : json.entrySet()) {
if (entry.getValue() != null) {
headers.set(entry.getKey(), String.valueOf(entry.getValue()));
}
}
} catch (Exception ignored) {
}
}
@FunctionalInterface
public interface DeltaExtractor {
String extract(JSONObject json);
}
public static class Counter {
private int value;
public void increment() {
value++;
}
public int get() {
return value;
}
}
}
@@ -0,0 +1,23 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.entity.AiCallLog;
import com.emotion.mapper.AiCallLogMapper;
import com.emotion.service.AiCallLogService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AiCallLogServiceImpl extends ServiceImpl<AiCallLogMapper, AiCallLog> implements AiCallLogService {
@Override
public List<AiCallLog> latest(Integer limit) {
int size = limit == null ? 50 : Math.max(1, Math.min(limit, 200));
return list(new LambdaQueryWrapper<AiCallLog>()
.eq(AiCallLog::getIsDeleted, 0)
.orderByDesc(AiCallLog::getCreateTime)
.last("limit " + size));
}
}
@@ -2,6 +2,8 @@ package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.entity.Message;
import com.emotion.entity.Conversation;
import com.emotion.entity.CozeApiCall;
@@ -14,6 +16,7 @@ import com.emotion.service.CozeApiCallService;
import com.emotion.service.EmotionRecordService;
import com.emotion.service.EmotionAnalysisService;
import com.emotion.service.AiConfigService;
import com.emotion.service.AiRuntimeService;
import com.emotion.entity.AiConfig;
import com.emotion.dto.request.*;
import com.emotion.dto.response.*;
@@ -79,6 +82,9 @@ public class AiChatServiceImpl implements AiChatService {
@Autowired
private AiConfigService aiConfigService;
@Autowired
private AiRuntimeService aiRuntimeService;
private static final String DEFAULT_USER_ID = "emotion-museum-user";
// 使用场景常量
@@ -117,9 +123,10 @@ public class AiChatServiceImpl implements AiChatService {
userMessage.setSender("user");
userMessage = messageService.createMessage(userMessage);
// 调用Coze API(传入messageId
String aiReply = sendMessageWithMessageId(request.getConversationId(), userMessage.getId(),
request.getMessage(), request.getUserId());
String aiReply = invokeRuntimeScene("chat", request.getMessage(), request.getUserId(), Map.of(
"conversationId", request.getConversationId(),
"userMessageId", userMessage.getId()
));
// 保存AI回复
Message aiMessage = new Message();
@@ -170,8 +177,9 @@ public class AiChatServiceImpl implements AiChatService {
// 构建总结请求
String summaryPrompt = "请为以下对话生成一个简洁的总结:\n\n" + conversationHistory;
// 调用AI生成总结 - 使用专门的总结bot
String summary = sendSummaryMessage(request.getConversationId(), summaryPrompt, request.getUserId());
String summary = invokeRuntimeScene("emotion_summary", summaryPrompt, request.getUserId(), Map.of(
"conversationId", request.getConversationId()
));
log.info("对话总结生成完成: conversationId={}", request.getConversationId());
@@ -445,16 +453,12 @@ public class AiChatServiceImpl implements AiChatService {
@Override
public String sendMessage(String conversationId, String userMessage, String userId) {
log.info("发送消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
// 创建API调用记录(不包含messageId,用于向后兼容)
CozeApiCall apiCall = createApiCallRecord(conversationId, null, userMessage, userId, "chat");
log.info("发送聊天消息到AI运行时: conversationId={}, userId={}", conversationId, userId);
try {
return executeCozeApiCall(apiCall, conversationId, userMessage, userId);
return invokeRuntimeScene("chat", userMessage, userId, Map.of("conversationId", conversationId));
} catch (Exception e) {
log.error("发送消息失败", e);
updateApiCallFailure(apiCall, e.getMessage());
log.error("发送聊天消息失败", e);
return "抱歉,AI服务暂时不可用,请稍后再试。";
}
}
@@ -535,10 +539,13 @@ public class AiChatServiceImpl implements AiChatService {
// Coze 中已经在工作流设置了提示词,目前不需要构建情绪分析提示词
// String emotionPrompt = buildEmotionAnalysisPrompt(chatHistory);
// 调用Coze API进行情绪分析总结
String conversationId = "emotion_summary_" + userId + "_"
+ today.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String emotionSummary = sendSummaryMessage(conversationId, chatHistory, userId);
String emotionSummary = invokeRuntimeScene("emotion_summary", chatHistory, userId, Map.of(
"conversationId", conversationId,
"messageCount", todayMessages.size(),
"recordDate", today.toString()
));
log.info("情绪分析总结生成完成: {}", emotionSummary);
// 解析AI返回的情绪分析结果
@@ -572,18 +579,39 @@ public class AiChatServiceImpl implements AiChatService {
return CompletableFuture.completedFuture(result);
}
private String invokeRuntimeScene(String sceneCode, String input, String userId, Map<String, Object> extraInputs) {
JSONObject inputs = new JSONObject();
inputs.put("input", input);
inputs.put("message", input);
inputs.put("prompt", input);
if (extraInputs != null) {
inputs.putAll(extraInputs);
}
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
runtimeRequest.setSceneCode(sceneCode);
runtimeRequest.setUserId(userId);
runtimeRequest.setInputs(inputs);
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
if (response == null) {
throw new IllegalStateException("AI_RUNTIME_EMPTY_RESPONSE");
}
if (!"success".equals(response.getStatus())) {
String message = response.getErrorMessage() != null ? response.getErrorMessage() : response.getErrorCode();
throw new IllegalStateException(message != null ? message : "AI_RUNTIME_FAILED");
}
return response.getOutput();
}
@Override
public String sendSummaryMessage(String conversationId, String userMessage, String userId) {
log.info("发送总结消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
// 创建API调用记录(总结不需要messageId)
CozeApiCall apiCall = createSummaryApiCallRecord(conversationId, null, userMessage, userId, "summary");
log.info("发送总结消息到AI运行时: conversationId={}, userId={}", conversationId, userId);
try {
return executeSummaryCozeApiCall(apiCall, conversationId, userMessage, userId);
return invokeRuntimeScene("emotion_summary", userMessage, userId, Map.of("conversationId", conversationId));
} catch (Exception e) {
log.error("发送总结消息失败", e);
updateApiCallFailure(apiCall, e.getMessage());
return "抱歉,AI总结服务暂时不可用,请稍后再试。";
}
}
@@ -759,34 +787,17 @@ public class AiChatServiceImpl implements AiChatService {
*/
private String sendMessageWithMessageId(String conversationId, String messageId, String userMessage,
String userId) {
log.info("发送消息到Coze AI: conversationId={}, messageId={}, userId={}", conversationId, messageId, userId);
// 1. 获取AI配置
AiConfig config = aiConfigService.getByConfigKey(COZE_CHAT_CONFIG_KEY);
if (config == null) {
log.error("未找到聊天场景的AI配置或配置已禁用: configKey={}", COZE_CHAT_CONFIG_KEY);
return "抱歉,AI服务暂时不可用,请稍后再试。";
}
// 2. 创建API调用记录(包含conversationId和messageId
CozeApiCall apiCall = createChatWorkflowApiCallRecord(config, conversationId, messageId, userMessage, userId);
log.info("发送聊天消息到AI运行时: conversationId={}, messageId={}, userId={}", conversationId, messageId, userId);
try {
// 3. 构建工作流请求参数
Map<String, Object> parameters = new HashMap<>();
parameters.put("input", userMessage);
parameters.put("user_id", userId);
// 4. 构建工作流请求体
Map<String, Object> requestBody = buildWorkflowRequest(config, parameters, userId);
// 5. 执行工作流调用(带API调用记录)
return executeWorkflowCallWithRecord(config, requestBody, COZE_CHAT_CONFIG_KEY, userId, apiCall);
return invokeRuntimeScene("chat", userMessage, userId, Map.of(
"conversationId", conversationId,
"userMessageId", messageId
));
} catch (Exception e) {
log.error("发送消息失败: conversationId={}, messageId={}, error={}",
log.error("发送聊天消息失败: conversationId={}, messageId={}, error={}",
conversationId, messageId, e.getMessage(), e);
updateApiCallFailure(apiCall, e.getMessage());
return "抱歉,AI服务暂时不可用,请稍后再试。";
}
}
@@ -2860,4 +2871,4 @@ public class AiChatServiceImpl implements AiChatService {
return result;
}
}
}
@@ -0,0 +1,59 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.entity.AiEndpointConfig;
import com.emotion.mapper.AiEndpointConfigMapper;
import com.emotion.service.AiEndpointConfigService;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
public class AiEndpointConfigServiceImpl extends ServiceImpl<AiEndpointConfigMapper, AiEndpointConfig> implements AiEndpointConfigService {
@Override
public List<AiEndpointConfig> listVisible() {
return list(new LambdaQueryWrapper<AiEndpointConfig>()
.eq(AiEndpointConfig::getIsDeleted, 0)
.orderByDesc(AiEndpointConfig::getCreateTime));
}
@Override
public AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint) {
applyDefaults(endpoint);
save(endpoint);
return endpoint;
}
@Override
public AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint) {
updateById(endpoint);
return getById(endpoint.getId());
}
@Override
public AiEndpointConfig getEnabledById(String id) {
AiEndpointConfig endpoint = getById(id);
if (endpoint == null || endpoint.getIsEnabled() == null || endpoint.getIsEnabled() != 1) {
return null;
}
return endpoint;
}
private void applyDefaults(AiEndpointConfig endpoint) {
if (!StringUtils.hasText(endpoint.getResponseMode())) {
endpoint.setResponseMode("streaming");
}
if (endpoint.getSupportStream() == null) {
endpoint.setSupportStream(1);
}
if (endpoint.getIsEnabled() == null) {
endpoint.setIsEnabled(1);
}
if (endpoint.getTimeoutMs() == null) {
endpoint.setTimeoutMs(60000);
}
}
}
@@ -0,0 +1,72 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.entity.AiProvider;
import com.emotion.mapper.AiProviderMapper;
import com.emotion.service.AiProviderService;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class AiProviderServiceImpl extends ServiceImpl<AiProviderMapper, AiProvider> implements AiProviderService {
@Override
public List<AiProvider> listVisible() {
return list(new LambdaQueryWrapper<AiProvider>()
.eq(AiProvider::getIsDeleted, 0)
.orderByDesc(AiProvider::getCreateTime))
.stream()
.map(this::maskSecret)
.collect(Collectors.toList());
}
@Override
public AiProvider saveProvider(AiProvider provider) {
if (provider.getIsEnabled() == null) {
provider.setIsEnabled(1);
}
if (!StringUtils.hasText(provider.getAuthType())) {
provider.setAuthType("bearer");
}
if (provider.getTimeoutMs() == null) {
provider.setTimeoutMs(60000);
}
save(provider);
return maskSecret(provider);
}
@Override
public AiProvider updateProvider(AiProvider provider) {
if (!StringUtils.hasText(provider.getApiKey()) || "******".equals(provider.getApiKey())) {
provider.setApiKey(null);
}
updateById(provider);
return maskSecret(getById(provider.getId()));
}
@Override
public AiProvider getEnabledById(String id) {
AiProvider provider = getById(id);
if (provider == null || provider.getIsEnabled() == null || provider.getIsEnabled() != 1) {
return null;
}
return provider;
}
private AiProvider maskSecret(AiProvider provider) {
if (provider == null) {
return null;
}
AiProvider copy = new AiProvider();
BeanUtils.copyProperties(provider, copy);
if (StringUtils.hasText(copy.getApiKey())) {
copy.setApiKey("******");
}
return copy;
}
}
@@ -0,0 +1,32 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.entity.AiSceneBinding;
import com.emotion.mapper.AiSceneBindingMapper;
import com.emotion.service.AiSceneBindingService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AiSceneBindingServiceImpl extends ServiceImpl<AiSceneBindingMapper, AiSceneBinding> implements AiSceneBindingService {
@Override
public List<AiSceneBinding> listVisible() {
return list(new LambdaQueryWrapper<AiSceneBinding>()
.eq(AiSceneBinding::getIsDeleted, 0)
.orderByDesc(AiSceneBinding::getPriority)
.orderByDesc(AiSceneBinding::getCreateTime));
}
@Override
public AiSceneBinding resolveScene(String sceneCode) {
return getOne(new LambdaQueryWrapper<AiSceneBinding>()
.eq(AiSceneBinding::getSceneCode, sceneCode)
.eq(AiSceneBinding::getIsEnabled, 1)
.eq(AiSceneBinding::getIsDeleted, 0)
.orderByDesc(AiSceneBinding::getPriority)
.last("limit 1"));
}
}
@@ -0,0 +1,117 @@
package com.emotion.service.impl;
import com.emotion.dto.response.asr.AsrTranscribeResponse;
import com.emotion.service.AsrService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
public class AsrServiceImpl implements AsrService {
private final RestTemplate restTemplate;
@Value("${emotion.asr.enabled:true}")
private boolean enabled;
@Value("${emotion.asr.engine-url:http://127.0.0.1:19120}")
private String engineUrl;
@Value("${emotion.asr.max-file-size:10485760}")
private long maxFileSize;
@Value("${emotion.asr.allowed-types:wav,mp3,m4a,mp4,aac,amr}")
private String allowedTypes;
public AsrServiceImpl(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public AsrTranscribeResponse transcribe(MultipartFile file) {
validate(file);
try {
ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return StringUtils.hasText(file.getOriginalFilename()) ? file.getOriginalFilename() : "voice.wav";
}
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/transcribe", request, Map.class);
Map<?, ?> data = response.getBody();
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
if (!success) {
String message = data == null ? "ASR service returned empty response" : String.valueOf(data.get("errorMessage"));
throw new IllegalStateException(message);
}
return AsrTranscribeResponse.builder()
.text(stringValue(data.get("text")))
.language(stringValue(data.get("language")))
.durationMs(longValue(data.get("durationMs")))
.engine(stringValue(data.get("engine")))
.model(stringValue(data.get("model")))
.build();
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("语音识别服务暂时不可用: " + e.getMessage(), e);
}
}
private void validate(MultipartFile file) {
if (!enabled) {
throw new IllegalStateException("语音识别功能未启用");
}
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请上传语音文件");
}
if (file.getSize() > maxFileSize) {
throw new IllegalArgumentException("语音文件过大,请控制在10MB以内");
}
String filename = file.getOriginalFilename();
String extension = "";
if (StringUtils.hasText(filename) && filename.contains(".")) {
extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
}
Set<String> allowed = Set.of(allowedTypes.toLowerCase(Locale.ROOT).split(","));
if (StringUtils.hasText(extension) && !allowed.contains(extension)) {
throw new IllegalArgumentException("不支持的语音格式: " + extension);
}
}
private String stringValue(Object value) {
if (value == null) return null;
String text = String.valueOf(value);
return "null".equals(text) ? null : text;
}
private Long longValue(Object value) {
if (value instanceof Number) {
return ((Number) value).longValue();
}
return null;
}
}
@@ -1,19 +1,22 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.request.EpicScriptCreateRequest;
import com.emotion.dto.request.EpicScriptInspirationRequest;
import com.emotion.dto.request.EpicScriptPageRequest;
import com.emotion.dto.request.EpicScriptUpdateRequest;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.EpicScriptInspirationResponse;
import com.emotion.dto.response.EpicScriptResponse;
import com.emotion.dto.response.InspirationSuggestionResponse;
import com.emotion.entity.EpicScript;
import com.emotion.mapper.EpicScriptMapper;
import com.emotion.service.AiChatService;
import com.emotion.service.AiRuntimeService;
import com.emotion.service.EpicScriptService;
import com.emotion.service.LifePathService;
import com.emotion.service.ScriptContextService;
@@ -62,14 +65,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
/**
* Coze工作流配置键 - 爽文剧本生成
*/
private static final String COZE_EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
@Autowired
@Lazy
private LifePathService lifePathService;
@Autowired
private AiChatService aiChatService;
private AiRuntimeService aiRuntimeService;
@Autowired
private ScriptContextService scriptContextService;
@@ -174,7 +175,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
script.setIsSelected(request.getIsSelected() != null && request.getIsSelected() ? 1 : 0);
// 调用Coze AI生成剧本内容
String aiGeneratedContent = generateScriptByAi(request, currentUserId);
String existingContent = extractExistingGeneratedContent(script.getPlotJson());
String aiGeneratedContent = StringUtils.hasText(existingContent)
? existingContent
: generateScriptByAi(request, currentUserId);
if (aiGeneratedContent != null) {
// 将AI生成的内容存储到plotJson中
Map<String, Object> plotJson = script.getPlotJson();
@@ -278,8 +282,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
String input = assembleScriptInput(request, userId);
log.info("开始调用AI生成剧本,用户ID: {}, 输入长度: {}", userId, input.length());
// 调用Coze工作流
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
String result = invokeScriptRuntime(request, input, userId);
log.info("AI生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
return result;
@@ -291,6 +294,49 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
}
}
private String invokeScriptRuntime(EpicScriptCreateRequest request, String input, String userId) {
JSONObject inputs = new JSONObject();
inputs.put("input", input);
inputs.put("prompt", StringUtils.hasText(request.getTheme()) ? request.getTheme() : input);
inputs.put("theme", request.getTheme());
inputs.put("style", request.getStyle());
inputs.put("length", request.getLength());
inputs.put("useSocialInsights", request.getUseSocialInsights());
return invokeRuntime("script_generate", inputs, userId);
}
private String invokeScriptRuntime(EpicScriptUpdateRequest request, String input, String userId) {
JSONObject inputs = new JSONObject();
inputs.put("input", input);
inputs.put("prompt", StringUtils.hasText(request.getTheme()) ? request.getTheme() : input);
inputs.put("theme", request.getTheme());
inputs.put("style", request.getStyle());
inputs.put("length", request.getLength());
inputs.put("useSocialInsights", request.getUseSocialInsights());
return invokeRuntime("script_generate", inputs, userId);
}
private String invokeRuntime(String sceneCode, JSONObject inputs, String userId) {
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
runtimeRequest.setSceneCode(sceneCode);
runtimeRequest.setUserId(userId);
runtimeRequest.setInputs(inputs);
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_EMPTY_RESPONSE");
}
return response.getOutput();
}
private String extractExistingGeneratedContent(Map<String, Object> plotJson) {
if (plotJson == null) {
return null;
}
Object fullContent = plotJson.get("fullContent");
return fullContent == null ? null : String.valueOf(fullContent);
}
/**
* 组装AI输入内容
* 将EpicScriptCreateRequest的字段组装为格式化字符串
@@ -311,7 +357,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
sb.append("【过往经历】").append(request.getLifeEventsSummary()).append("\n");
}
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
String socialContext = scriptContextService == null
? null
: scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
if (StringUtils.hasText(socialContext)) {
sb.append(socialContext).append("\n");
}
@@ -361,6 +409,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
return sb.toString().trim();
}
private String assembleScriptInput(EpicScriptCreateRequest request) {
return assembleScriptInput(request, null);
}
/**
* 获取风格描述
*
@@ -478,8 +530,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
String input = assembleUpdateScriptInput(request, script, userId);
log.info("开始调用AI重新生成剧本,用户ID: {}, 剧本ID: {}", userId, script.getId());
// 调用Coze工作流
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
String result = invokeScriptRuntime(request, input, userId);
log.info("AI重新生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
return result;
@@ -514,7 +565,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
sb.append("【过往经历】").append(lifeEventsSummary).append("\n");
}
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
String socialContext = scriptContextService == null
? null
: scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
if (StringUtils.hasText(socialContext)) {
sb.append(socialContext).append("\n");
}
@@ -3,16 +3,19 @@ package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.common.PageResult;
import com.emotion.dto.request.LifeEventCreateRequest;
import com.emotion.dto.request.LifeEventPageRequest;
import com.emotion.dto.request.LifeEventUpdateRequest;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.LifeEventResponse;
import com.emotion.entity.LifeEvent;
import com.emotion.mapper.LifeEventMapper;
import com.emotion.service.AiRuntimeService;
import com.emotion.service.LifeEventService;
import com.emotion.util.UserContextHolder;
import com.emotion.service.AiChatService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.BeanUtils;
@@ -44,13 +47,8 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
private static final DateTimeFormatter DATE_ONLY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
/**
* Coze工作流配置键 - AI疗愈
*/
private static final String COZE_HEALING_CONFIG_KEY = "coze.user.dairy.summary";
@Autowired
private AiChatService aiChatService;
private AiRuntimeService aiRuntimeService;
@Override
public PageResult<LifeEventResponse> getPageByCurrentUser(LifeEventPageRequest request) {
@@ -158,10 +156,11 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
}
// 调用Coze AI进行疗愈回复
String aiGeneratedContent = generateHealingByAi(event);
if (StringUtils.hasText(aiGeneratedContent)) {
event.setAiReply(aiGeneratedContent);
if (!StringUtils.hasText(event.getAiReply())) {
String aiGeneratedContent = generateHealingByAi(event);
if (StringUtils.hasText(aiGeneratedContent)) {
event.setAiReply(aiGeneratedContent);
}
}
this.save(event);
@@ -180,8 +179,23 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
String input = assembleHealingInput(event);
log.info("开始调用AI生成疗愈回复,用户ID: {}, 输入长度: {}", event.getUserId(), input.length());
// 调用Coze工作流
String result = aiChatService.callWorkflowByConfigKey(COZE_HEALING_CONFIG_KEY, input, event.getUserId());
JSONObject inputs = new JSONObject();
inputs.put("mode", "life_event_analysis");
inputs.put("input", input);
inputs.put("message", input);
inputs.put("content", event.getContent());
inputs.put("title", event.getTitle());
inputs.put("emotionType", event.getEmotionType());
inputs.put("eventType", event.getEventType());
inputs.put("eventDate", event.getEventDateText());
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
runtimeRequest.setSceneCode("life_healing");
runtimeRequest.setUserId(event.getUserId());
runtimeRequest.setInputs(inputs);
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
String result = response != null ? response.getOutput() : null;
log.info("AI生成疗愈回复完成,用户ID: {}, 结果长度: {}", event.getUserId(), result != null ? result.length() : 0);
return result;
@@ -238,6 +252,8 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
return null;
}
boolean hasClientAiReply = request.getAiReply() != null;
// 更新字段
if (StringUtils.hasText(request.getEventType())) {
event.setEventType(request.getEventType());
@@ -273,10 +289,11 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
}
// 调用Coze AI进行疗愈回复
String aiGeneratedContent = generateHealingByAi(event);
if (StringUtils.hasText(aiGeneratedContent)) {
event.setAiReply(aiGeneratedContent);
if (!hasClientAiReply) {
String aiGeneratedContent = generateHealingByAi(event);
if (StringUtils.hasText(aiGeneratedContent)) {
event.setAiReply(aiGeneratedContent);
}
}
this.updateById(event);
@@ -1,13 +1,16 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.request.WebSocketRequest;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.dto.websocket.ChatRequest;
import com.emotion.dto.websocket.ConnectRequest;
import com.emotion.dto.websocket.WebSocketMessage;
import com.emotion.entity.Message;
import com.emotion.entity.Conversation;
import com.emotion.service.WebSocketService;
import com.emotion.service.AiChatService;
import com.emotion.service.AiRuntimeService;
import com.emotion.service.MessageService;
import com.emotion.service.ConversationService;
import com.emotion.util.SnowflakeIdGenerator;
@@ -36,7 +39,7 @@ public class WebSocketServiceImpl implements WebSocketService {
private SimpMessagingTemplate messagingTemplate;
@Autowired
private AiChatService aiChatService;
private AiRuntimeService aiRuntimeService;
@Autowired
private MessageService messageService;
@@ -238,16 +241,11 @@ public class WebSocketServiceImpl implements WebSocketService {
userMessage.setCozeContentType("text");
userMessage = messageService.createMessage(userMessage);
// 调用AI服务(WebSocket专用方法,传递messageId
String aiReply = aiChatService.sendChatMessageForWebSocket(
conversationId,
userMessage.getId(), // 传递用户消息ID
request.getContent(),
userId
);
// 根据换行符分割AI回复并按顺序发送多条消息
sendAiReplyInParts(userId, conversationId, aiReply);
String aiMessageId = snowflakeIdGenerator.nextIdAsString();
String aiReply = streamChatReply(userId, conversationId, userMessage.getId(), aiMessageId, request.getContent());
if (StringUtils.hasText(aiReply)) {
saveAiMessageOnly(userId, conversationId, aiMessageId, aiReply);
}
// 更新会话的最后活跃时间和消息数量
updateConversationActivity(conversationId);
@@ -262,6 +260,85 @@ public class WebSocketServiceImpl implements WebSocketService {
/**
* 发送错误消息
*/
private String streamChatReply(String userId, String conversationId, String userMessageId, String aiMessageId, String content) {
StringBuilder output = new StringBuilder();
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
runtimeRequest.setSceneCode("chat");
runtimeRequest.setUserId(userId);
runtimeRequest.setRequestId(aiMessageId);
JSONObject inputs = new JSONObject();
inputs.put("input", content);
inputs.put("message", content);
inputs.put("prompt", content);
inputs.put("conversationId", conversationId);
inputs.put("userMessageId", userMessageId);
runtimeRequest.setInputs(inputs);
aiRuntimeService.invokeStream(runtimeRequest, event -> {
if ("delta".equals(event.getType()) && event.getContent() != null) {
output.append(event.getContent());
}
sendAiStreamEvent(userId, conversationId, aiMessageId, event);
});
return output.toString();
}
private void sendAiStreamEvent(String userId, String conversationId, String aiMessageId, AiStreamEvent event) {
String type = switch (event.getType()) {
case "start" -> "AI_STREAM_START";
case "delta" -> "AI_STREAM_DELTA";
case "done" -> "AI_STREAM_DONE";
case "error" -> "AI_STREAM_ERROR";
default -> "AI_STREAM_EVENT";
};
JSONObject data = new JSONObject();
data.put("eventType", event.getType());
data.put("code", event.getCode());
data.put("message", event.getMessage());
data.put("seq", event.getSeq());
data.put("timestamp", event.getTimestamp());
data.put("metadata", event.getMetadata());
String messageContent = event.getContent();
if (!StringUtils.hasText(messageContent)) {
messageContent = StringUtils.hasText(event.getMessage()) ? event.getMessage() : "";
}
WebSocketMessage wsMessage = WebSocketMessage.builder()
.messageId(aiMessageId)
.conversationId(conversationId)
.type(type)
.content(messageContent)
.senderId("ai")
.senderType("AI")
.status("SENT")
.createTime(LocalDateTime.now())
.data(data)
.build();
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", wsMessage);
if (conversationId != null) {
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, wsMessage);
}
}
private void saveAiMessageOnly(String userId, String conversationId, String messageId, String content) {
Message aiMessage = new Message();
aiMessage.setId(messageId);
aiMessage.setConversationId(conversationId);
aiMessage.setUserId(userId);
aiMessage.setCreateBy("ai");
aiMessage.setContent(content);
aiMessage.setType("text");
aiMessage.setSender("ai");
aiMessage.setCozeRole("assistant");
aiMessage.setCozeContentType("text");
aiMessage.setTimestamp(LocalDateTime.now());
messageService.createMessage(aiMessage);
}
private void sendErrorMessage(String userId, String errorContent) {
WebSocketMessage errorMessage = WebSocketMessage.builder()
.messageId(UUID.randomUUID().toString())
@@ -631,4 +708,4 @@ public class WebSocketServiceImpl implements WebSocketService {
.timestamp(webSocketRequest.getTimestamp())
.build();
}
}
}