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.AiTestTemplateResponse; 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.service.ScriptContextService; import com.emotion.service.ai.AiProviderAdapter; import com.emotion.util.UserContextHolder; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; @Slf4j @Service public class AiRuntimeServiceImpl implements AiRuntimeService { private final AiSceneBindingService sceneBindingService; private final AiEndpointConfigService endpointConfigService; private final AiProviderService providerService; private final AiCallLogService callLogService; private final ScriptContextService scriptContextService; private final List adapters; public AiRuntimeServiceImpl(AiSceneBindingService sceneBindingService, AiEndpointConfigService endpointConfigService, AiProviderService providerService, AiCallLogService callLogService, ScriptContextService scriptContextService, List adapters) { this.sceneBindingService = sceneBindingService; this.endpointConfigService = endpointConfigService; this.providerService = providerService; this.callLogService = callLogService; this.scriptContextService = scriptContextService; this.adapters = adapters; } @Override public void invokeStream(AiRuntimeRequest request, Consumer consumer) { String requestId = StringUtils.hasText(request.getRequestId()) ? request.getRequestId() : UUID.randomUUID().toString(); request.setRequestId(requestId); enrichInputs(request); long startedAt = System.currentTimeMillis(); AtomicLong firstTokenAt = new AtomicLong(0); AtomicInteger chunks = new AtomicInteger(0); StringBuilder output = new StringBuilder(); AiCallLog callLog = new AiCallLog(); callLog.setRequestId(requestId); callLog.setSceneCode(request.getSceneCode()); callLog.setUserId(resolveUserId(request)); callLog.setUserName(request.getUserName()); callLog.setInputText(JSON.toJSONString(request.getInputs())); callLog.setStatus("running"); try { RuntimeTarget target = resolveTarget(request); callLog.setProviderCode(target.provider.getProviderCode()); callLog.setEndpointCode(target.endpoint.getEndpointCode()); callLogService.save(callLog); consumer.accept(AiStreamEvent.start(request.getSceneCode())); target.adapter.stream(target.provider, target.endpoint, request, event -> { if ("delta".equals(event.getType())) { chunks.incrementAndGet(); if (firstTokenAt.compareAndSet(0, System.currentTimeMillis())) { log.debug("AI first token emitted, scene={}, requestId={}", request.getSceneCode(), requestId); } if (event.getContent() != null) { output.append(event.getContent()); } } consumer.accept(event); }); callLog.setStatus("success"); callLog.setOutputText(output.toString()); callLog.setStreamChunks(chunks.get()); callLog.setFirstTokenMs(firstTokenAt.get() == 0 ? null : firstTokenAt.get() - startedAt); callLog.setDurationMs(System.currentTimeMillis() - startedAt); callLogService.updateById(callLog); emitDone(consumer, Map.of( "requestId", requestId, "streamChunks", chunks.get(), "durationMs", callLog.getDurationMs() )); } catch (Exception e) { String code = normalizeErrorCode(e); callLog.setOutputText(output.toString()); callLog.setStreamChunks(chunks.get()); callLog.setFirstTokenMs(firstTokenAt.get() == 0 ? null : firstTokenAt.get() - startedAt); callLog.setDurationMs(System.currentTimeMillis() - startedAt); if (StringUtils.hasText(output.toString())) { callLog.setStatus("success"); callLog.setErrorCode(null); callLog.setErrorMessage(null); saveOrUpdateLog(callLog); emitDone(consumer, recoveredDoneMetadata(requestId, chunks.get(), callLog.getDurationMs(), code, e.getMessage())); return; } callLog.setStatus("failed"); callLog.setErrorCode(code); callLog.setErrorMessage(e.getMessage()); saveOrUpdateLog(callLog); consumer.accept(AiStreamEvent.error(code, e.getMessage())); } } @Override public AiRuntimeTestResponse test(AiRuntimeRequest request) { long startedAt = System.currentTimeMillis(); StringBuilder output = new StringBuilder(); AtomicInteger chunks = new AtomicInteger(0); final String[] errorCode = new String[1]; final String[] errorMessage = new String[1]; invokeStream(request, event -> { if ("delta".equals(event.getType()) && event.getContent() != null) { chunks.incrementAndGet(); output.append(event.getContent()); } else if ("error".equals(event.getType())) { errorCode[0] = event.getCode(); errorMessage[0] = event.getMessage(); } }); return AiRuntimeTestResponse.builder() .sceneCode(request.getSceneCode()) .status(errorCode[0] == null || StringUtils.hasText(output.toString()) ? "success" : "failed") .output(output.toString()) .streamChunks(chunks.get()) .durationMs(System.currentTimeMillis() - startedAt) .errorCode(StringUtils.hasText(output.toString()) ? null : errorCode[0]) .errorMessage(StringUtils.hasText(output.toString()) ? null : errorMessage[0]) .build(); } @Override public AiRuntimeTestResponse testEndpoint(String endpointId, Map inputs) { long startedAt = System.currentTimeMillis(); StringBuilder output = new StringBuilder(); AtomicInteger chunks = new AtomicInteger(0); final String[] errorCode = new String[1]; final String[] errorMessage = new String[1]; invokeEndpointStream(endpointId, inputs, event -> { if ("delta".equals(event.getType()) && event.getContent() != null) { chunks.incrementAndGet(); output.append(event.getContent()); } else if ("error".equals(event.getType())) { errorCode[0] = event.getCode(); errorMessage[0] = event.getMessage(); } }); return AiRuntimeTestResponse.builder() .sceneCode("") .status(errorCode[0] == null || StringUtils.hasText(output.toString()) ? "success" : "failed") .output(output.toString()) .streamChunks(chunks.get()) .durationMs(System.currentTimeMillis() - startedAt) .errorCode(StringUtils.hasText(output.toString()) ? null : errorCode[0]) .errorMessage(StringUtils.hasText(output.toString()) ? null : errorMessage[0]) .build(); } @Override public void invokeEndpointStream(String endpointId, Map inputs, Consumer consumer) { long startedAt = System.currentTimeMillis(); AtomicLong firstTokenAt = new AtomicLong(0); AtomicInteger chunks = new AtomicInteger(0); Object inputRequestId = inputs == null ? null : inputs.get("requestId"); String requestId = inputRequestId != null && StringUtils.hasText(String.valueOf(inputRequestId)) ? String.valueOf(inputRequestId) : UUID.randomUUID().toString(); StringBuilder output = new StringBuilder(); AiEndpointConfig endpoint = endpointConfigService.getEnabledById(endpointId); if (endpoint == null) { throw new IllegalStateException("AI_ENDPOINT_DISABLED"); } AiProvider provider = providerService.getEnabledById(endpoint.getProviderId()); if (provider == null) { throw new IllegalStateException("AI_PROVIDER_DISABLED"); } AiProviderAdapter adapter = adapters.stream() .filter(item -> item.supports(provider.getProviderType())) .findFirst() .orElseThrow(() -> new IllegalStateException("AI_PROVIDER_ADAPTER_NOT_FOUND")); AiRuntimeRequest request = new AiRuntimeRequest(); request.setInputs(inputs == null ? new com.alibaba.fastjson2.JSONObject() : new com.alibaba.fastjson2.JSONObject(inputs)); request.setEndpointId(endpointId); request.setUserId(resolveUserId(request)); request.setUserName(UserContextHolder.getCurrentUsername()); request.setUserType(UserContextHolder.getCurrentUserType()); request.setRequestId(requestId); enrichInputs(request); AiCallLog callLog = new AiCallLog(); callLog.setRequestId(requestId); callLog.setEndpointCode(endpoint.getEndpointCode()); callLog.setProviderCode(provider.getProviderCode()); callLog.setUserId(request.getUserId()); callLog.setUserName(request.getUserName()); callLog.setInputText(JSON.toJSONString(request.getInputs())); callLog.setStatus("running"); callLogService.save(callLog); consumer.accept(AiStreamEvent.start(endpoint.getEndpointCode())); try { adapter.stream(provider, endpoint, request, event -> { if ("delta".equals(event.getType())) { chunks.incrementAndGet(); if (firstTokenAt.compareAndSet(0, System.currentTimeMillis())) { log.debug("AI first token emitted, endpoint={}, requestId={}", endpoint.getEndpointCode(), requestId); } if (event.getContent() != null) { output.append(event.getContent()); } } consumer.accept(event); }); callLog.setStatus("success"); callLog.setOutputText(output.toString()); callLog.setStreamChunks(chunks.get()); callLog.setFirstTokenMs(firstTokenAt.get() == 0 ? null : firstTokenAt.get() - startedAt); callLog.setDurationMs(System.currentTimeMillis() - startedAt); callLogService.updateById(callLog); emitDone(consumer, Map.of( "requestId", requestId, "streamChunks", chunks.get(), "durationMs", callLog.getDurationMs() )); } catch (Exception e) { String code = normalizeErrorCode(e); callLog.setOutputText(output.toString()); callLog.setStreamChunks(chunks.get()); callLog.setFirstTokenMs(firstTokenAt.get() == 0 ? null : firstTokenAt.get() - startedAt); callLog.setDurationMs(System.currentTimeMillis() - startedAt); if (StringUtils.hasText(output.toString())) { callLog.setStatus("success"); callLog.setErrorCode(null); callLog.setErrorMessage(null); saveOrUpdateLog(callLog); emitDone(consumer, recoveredDoneMetadata(requestId, chunks.get(), callLog.getDurationMs(), code, e.getMessage())); return; } callLog.setStatus("failed"); callLog.setErrorCode(code); callLog.setErrorMessage(e.getMessage()); saveOrUpdateLog(callLog); consumer.accept(AiStreamEvent.error(code, e.getMessage())); } } @Override public AiTestTemplateResponse buildEndpointTestTemplate(String endpointId) { AiEndpointConfig endpoint = endpointConfigService.getById(endpointId); if (endpoint == null || Integer.valueOf(1).equals(endpoint.getIsDeleted())) { throw new IllegalArgumentException("AI_ENDPOINT_NOT_FOUND"); } AiProvider provider = providerService.getById(endpoint.getProviderId()); if (provider == null || Integer.valueOf(1).equals(provider.getIsDeleted())) { throw new IllegalArgumentException("AI_PROVIDER_NOT_FOUND"); } return buildTemplate(null, endpoint, provider); } @Override public AiTestTemplateResponse buildSceneTestTemplate(String sceneCode) { AiSceneBinding scene = sceneBindingService.resolveScene(sceneCode); if (scene == null) { throw new IllegalArgumentException("AI_SCENE_NOT_BOUND"); } AiEndpointConfig endpoint = endpointConfigService.getById(scene.getEndpointId()); if (endpoint == null || Integer.valueOf(1).equals(endpoint.getIsDeleted())) { throw new IllegalArgumentException("AI_ENDPOINT_NOT_FOUND"); } AiProvider provider = providerService.getById(endpoint.getProviderId()); if (provider == null || Integer.valueOf(1).equals(provider.getIsDeleted())) { throw new IllegalArgumentException("AI_PROVIDER_NOT_FOUND"); } return buildTemplate(sceneCode, endpoint, provider); } private AiTestTemplateResponse buildTemplate(String sceneCode, AiEndpointConfig endpoint, AiProvider provider) { LinkedHashMap inputs = new LinkedHashMap<>(); applyDefaultInputs(inputs, endpoint.getDefaultInputs()); applyProviderSampleInputs(inputs, sceneCode, endpoint, provider); List fields = new ArrayList<>(); inputs.forEach((key, value) -> fields.add(AiTestTemplateResponse.ParamField.builder() .name(key) .label(paramLabel(key)) .type(paramType(value)) .value(value) .required(requiredParam(key, provider.getProviderType())) .placeholder(paramPlaceholder(key)) .build())); return AiTestTemplateResponse.builder() .sceneCode(sceneCode) .endpointId(endpoint.getId()) .endpointCode(endpoint.getEndpointCode()) .endpointName(endpoint.getEndpointName()) .providerType(provider.getProviderType()) .inputs(inputs) .paramFields(fields) .build(); } private void applyDefaultInputs(LinkedHashMap inputs, String defaultInputs) { if (!StringUtils.hasText(defaultInputs)) { return; } try { JSONObject parsed = JSON.parseObject(defaultInputs); parsed.forEach((key, value) -> { if (value instanceof JSONObject && ((JSONObject) value).containsKey("_meta")) { inputs.put(key, ((JSONObject) value).get("value")); } else { inputs.put(key, value); } }); } catch (Exception ignored) { } } private void applyProviderSampleInputs(LinkedHashMap inputs, String sceneCode, AiEndpointConfig endpoint, AiProvider provider) { String providerType = provider.getProviderType(); String sample = samplePrompt(sceneCode, endpoint); if ("dify".equalsIgnoreCase(providerType)) { inputs.putIfAbsent("query", sample); inputs.putIfAbsent("inputs", Map.of("input", sample, "user_id", "admin-test-user")); inputs.putIfAbsent("response_mode", "streaming"); inputs.putIfAbsent("conversation_id", ""); inputs.putIfAbsent("user", "admin-test-user"); inputs.putIfAbsent("user_id", "admin-test-user"); return; } inputs.putIfAbsent("input", sample); inputs.putIfAbsent("prompt", sample); inputs.putIfAbsent("message", sample); inputs.putIfAbsent("user_id", "admin-test-user"); if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) { inputs.putIfAbsent("conversationId", "admin-test-conversation"); } } private String samplePrompt(String sceneCode, AiEndpointConfig endpoint) { String code = StringUtils.hasText(sceneCode) ? sceneCode : endpoint.getEndpointCode(); if (code == null) { return "请用一句中文回复测试成功。"; } if (code.contains("script") || code.contains("life.generate")) { return "请生成一个关于普通人重新找回生活热情的短剧本,要求结构清晰、中文输出。"; } if (code.contains("short_story") || code.contains("story")) { return "请生成一篇 300 字以内的治愈短篇小说,主题是雨后的新开始。"; } if (code.contains("diary") || code.contains("healing") || code.contains("life")) { return "今天工作压力很大,但傍晚散步时感觉心情慢慢平静下来,请给我一段温和的回应。"; } if (code.contains("summary")) { return "用户今天先表达焦虑,随后通过散步和朋友聊天逐渐恢复平静,请生成一段情绪总结。"; } if (code.contains("chat")) { return "你好,请用一句中文回复:AI 接口测试成功。"; } return "请用一句中文回复测试成功。"; } private String paramLabel(String key) { Map labels = Map.ofEntries( Map.entry("input", "输入内容"), Map.entry("prompt", "提示词"), Map.entry("message", "消息内容"), Map.entry("query", "用户问题"), Map.entry("inputs", "Dify 变量"), Map.entry("response_mode", "响应模式"), Map.entry("conversation_id", "会话 ID"), Map.entry("user", "用户标识"), Map.entry("user_id", "用户标识"), Map.entry("conversationId", "会话 ID") ); return labels.getOrDefault(key, key); } private String paramType(Object value) { if (value instanceof Number) { return "number"; } if (value instanceof Boolean) { return "boolean"; } if (value instanceof Map || value instanceof JSONObject) { return "json"; } String text = value == null ? "" : String.valueOf(value); return text.length() > 60 ? "textarea" : "string"; } private Boolean requiredParam(String key, String providerType) { if ("dify".equalsIgnoreCase(providerType)) { return "query".equals(key) || "response_mode".equals(key) || "user".equals(key); } return "input".equals(key) || "user_id".equals(key); } private String paramPlaceholder(String key) { if ("inputs".equals(key)) { return "{\"input\":\"测试内容\"}"; } return "请输入" + paramLabel(key); } private RuntimeTarget resolveTarget(AiRuntimeRequest request) { if (!StringUtils.hasText(request.getSceneCode())) { throw new IllegalArgumentException("AI_SCENE_REQUIRED"); } if (!StringUtils.hasText(request.getUserId())) { request.setUserId(resolveUserId(request)); } AiSceneBinding scene = sceneBindingService.resolveScene(request.getSceneCode()); if (scene == null) { throw new IllegalStateException("AI_SCENE_NOT_BOUND"); } AiEndpointConfig endpoint = endpointConfigService.getEnabledById(scene.getEndpointId()); if (endpoint == null) { throw new IllegalStateException("AI_ENDPOINT_DISABLED"); } if (Integer.valueOf(1).equals(scene.getRequiredStream()) && !Integer.valueOf(1).equals(endpoint.getSupportStream())) { throw new IllegalStateException("AI_ENDPOINT_STREAM_REQUIRED"); } AiProvider provider = providerService.getEnabledById(endpoint.getProviderId()); if (provider == null) { throw new IllegalStateException("AI_PROVIDER_DISABLED"); } AiProviderAdapter adapter = adapters.stream() .filter(item -> item.supports(provider.getProviderType())) .findFirst() .orElseThrow(() -> new IllegalStateException("AI_PROVIDER_ADAPTER_NOT_FOUND")); return new RuntimeTarget(scene, endpoint, provider, adapter); } private String resolveUserId(AiRuntimeRequest request) { if (StringUtils.hasText(request.getUserId())) { return request.getUserId(); } String currentUserId = UserContextHolder.getCurrentUserId(); return StringUtils.hasText(currentUserId) ? currentUserId : "anonymous"; } private void enrichInputs(AiRuntimeRequest request) { if (request.getInputs() == null) { request.setInputs(new com.alibaba.fastjson2.JSONObject()); } String userId = resolveUserId(request); request.setUserId(userId); request.getInputs().put("userId", userId); request.getInputs().put("currentUserId", userId); request.getInputs().put("user_id", userId); if (StringUtils.hasText(request.getUserName())) { request.getInputs().put("userName", request.getUserName()); request.getInputs().put("username", request.getUserName()); } if (StringUtils.hasText(request.getUserType())) { request.getInputs().put("userType", request.getUserType()); } if (StringUtils.hasText(request.getRequestId())) { request.getInputs().put("requestId", request.getRequestId()); } enrichSceneInputs(request, userId); } private void enrichSceneInputs(AiRuntimeRequest request, String userId) { if (!StringUtils.hasText(request.getSceneCode())) { return; } if ("script_generate".equals(request.getSceneCode()) || "short_story_generate".equals(request.getSceneCode())) { Boolean useSocialInsights = request.getInputs().getBoolean("useSocialInsights"); String socialInsightContext = scriptContextService.buildSocialInsightContext(userId, useSocialInsights); if (StringUtils.hasText(socialInsightContext)) { request.getInputs().put("socialInsightContext", socialInsightContext); } } } private String normalizeErrorCode(Exception e) { String message = e.getMessage(); if (message != null && message.startsWith("AI_")) { return message; } if (message != null && message.contains("timed out")) { return "AI_STREAM_TIMEOUT"; } return "AI_STREAM_INTERRUPTED"; } private Map recoveredDoneMetadata(String requestId, int streamChunks, Long durationMs, String code, String message) { Map metadata = new LinkedHashMap<>(); metadata.put("requestId", requestId); metadata.put("streamChunks", streamChunks); metadata.put("durationMs", durationMs); metadata.put("recovered", true); metadata.put("warningCode", code); if (StringUtils.hasText(message)) { metadata.put("warningMessage", message); } return metadata; } private void emitDone(Consumer consumer, Map metadata) { try { consumer.accept(AiStreamEvent.done(metadata)); } catch (Exception e) { log.debug("AI stream done event skipped after output persisted: {}", e.getMessage()); } } private void saveOrUpdateLog(AiCallLog callLog) { if (StringUtils.hasText(callLog.getId())) { callLogService.updateById(callLog); } else { callLogService.save(callLog); } } private static class RuntimeTarget { private final AiSceneBinding scene; private final AiEndpointConfig endpoint; private final AiProvider provider; private final AiProviderAdapter adapter; private RuntimeTarget(AiSceneBinding scene, AiEndpointConfig endpoint, AiProvider provider, AiProviderAdapter adapter) { this.scene = scene; this.endpoint = endpoint; this.provider = provider; this.adapter = adapter; } } }