diff --git a/.superpowers/brainstorm/manual-20260522221312/.server-stopped b/.superpowers/brainstorm/manual-20260522221312/.server-stopped new file mode 100644 index 0000000..b8e9755 --- /dev/null +++ b/.superpowers/brainstorm/manual-20260522221312/.server-stopped @@ -0,0 +1 @@ +{"reason":"owner process exited","timestamp":1779459252481} diff --git a/backend-single/asr-service/README.md b/backend-single/asr-service/README.md new file mode 100644 index 0000000..6cd9e37 --- /dev/null +++ b/backend-single/asr-service/README.md @@ -0,0 +1,17 @@ +# Emotion Museum ASR Service + +Private speech-to-text service for the mini program voice input. + +Install on `101.200.208.45`: + +```bash +cd /data/programs/emotion-museum/asr-service +python3.11 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt + +uvicorn app:app --host 127.0.0.1 --port 19120 +curl http://127.0.0.1:19120/health +``` + +The service uses FunASR ONNX Runtime with a local Chinese Paraformer model on CPU and is intended to stay private on localhost. The Java backend exposes the authenticated public API. diff --git a/backend-single/asr-service/app.py b/backend-single/asr-service/app.py new file mode 100644 index 0000000..7905fb0 --- /dev/null +++ b/backend-single/asr-service/app.py @@ -0,0 +1,114 @@ +import os +import sys +import tempfile +import time +import types +import importlib.machinery +from pathlib import Path +from threading import Lock + +from fastapi import FastAPI, File, UploadFile + +app = FastAPI(title="Emotion Museum ASR") + +MODEL_NAME = os.getenv("ASR_MODEL", "/data/programs/emotion-museum/asr-service/models/paraformer-zh-onnx") +DEVICE = os.getenv("ASR_DEVICE", "cpu") +WORK_DIR = Path(os.getenv("ASR_WORK_DIR", "/tmp/emotion-museum-asr")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +_model = None +_model_lock = Lock() + + +def get_model(): + global _model + with _model_lock: + if _model is None: + # funasr-onnx imports the optional SenseVoice module from package + # __init__, which imports torch even when we only use Paraformer. + # This service intentionally runs the ONNX path without PyTorch. + if "torch" not in sys.modules: + torch_stub = types.ModuleType("torch") + torch_stub.__spec__ = importlib.machinery.ModuleSpec("torch", loader=None) + torch_stub.Tensor = type("Tensor", (), {}) + sys.modules["torch"] = torch_stub + from funasr_onnx import Paraformer + + _model = Paraformer( + MODEL_NAME, + batch_size=1, + device_id=-1, + quantize=True, + intra_op_num_threads=2, + ) + return _model + + +def clean_text(text): + if isinstance(text, (list, tuple)): + text = text[0] if text else "" + if not text: + return "" + markers = ["<|zh|>", "<|en|>", "<|yue|>", "<|ja|>", "<|ko|>", "<|nospeech|>", "<|withitn|>", "<|woitn|>"] + for marker in markers: + text = text.replace(marker, "") + return text.strip() + + +@app.get("/health") +def health(): + return { + "status": "ok", + "engine": "funasr-onnx", + "model": MODEL_NAME, + "device": DEVICE, + "loaded": _model is not None, + } + + +@app.post("/transcribe") +async def transcribe(file: UploadFile = File(...)): + started = time.time() + suffix = Path(file.filename or "audio.wav").suffix or ".wav" + tmp_path = None + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=WORK_DIR) as tmp: + tmp_path = Path(tmp.name) + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + tmp.write(chunk) + + model = get_model() + result = model([str(tmp_path)]) + first = result[0] if isinstance(result, list) and result else result + text = clean_text(first.get("preds", first.get("text", "")) if isinstance(first, dict) else str(first or "")) + language = first.get("language") if isinstance(first, dict) else None + + return { + "success": bool(text), + "text": text, + "language": language, + "durationMs": int((time.time() - started) * 1000), + "engine": "funasr-onnx", + "model": MODEL_NAME, + "errorMessage": None if text else "empty recognition result", + } + except Exception as exc: + return { + "success": False, + "text": "", + "language": None, + "durationMs": int((time.time() - started) * 1000), + "engine": "funasr-onnx", + "model": MODEL_NAME, + "errorMessage": str(exc), + } + finally: + if tmp_path: + try: + tmp_path.unlink(missing_ok=True) + except Exception: + pass diff --git a/backend-single/asr-service/emotion-museum-asr.service b/backend-single/asr-service/emotion-museum-asr.service new file mode 100644 index 0000000..cff6b6b --- /dev/null +++ b/backend-single/asr-service/emotion-museum-asr.service @@ -0,0 +1,16 @@ +[Unit] +Description=Emotion Museum ASR Service +After=network.target + +[Service] +Type=simple +WorkingDirectory=/data/programs/emotion-museum/asr-service +Environment=ASR_MODEL=/data/programs/emotion-museum/asr-service/models/paraformer-zh-onnx +Environment=ASR_DEVICE=cpu +Environment=ASR_WORK_DIR=/tmp/emotion-museum-asr +ExecStart=/data/programs/emotion-museum/asr-service/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 19120 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/backend-single/asr-service/requirements.txt b/backend-single/asr-service/requirements.txt new file mode 100644 index 0000000..0ce1dc0 --- /dev/null +++ b/backend-single/asr-service/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic==2.7.4 +python-multipart==0.0.20 +funasr-onnx==0.4.1 +modelscope==1.22.3 +onnxruntime==1.26.0 +soundfile==0.13.1 diff --git a/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java b/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java new file mode 100644 index 0000000..cfe80cc --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java @@ -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> providers() { + return Result.success(providerService.listVisible()); + } + + @PostMapping("/providers") + public Result createProvider(@RequestBody AiProvider provider) { + return Result.success(providerService.saveProvider(provider)); + } + + @PutMapping("/providers") + public Result updateProvider(@RequestBody AiProvider provider) { + return Result.success(providerService.updateProvider(provider)); + } + + @DeleteMapping("/providers") + public Result deleteProvider(@RequestParam String id) { + providerService.removeById(id); + return Result.success(); + } + + @GetMapping("/endpoints") + public Result> endpoints() { + return Result.success(endpointConfigService.listVisible()); + } + + @PostMapping("/endpoints") + public Result createEndpoint(@RequestBody AiEndpointConfig endpoint) { + return Result.success(endpointConfigService.saveEndpoint(endpoint)); + } + + @PutMapping("/endpoints") + public Result updateEndpoint(@RequestBody AiEndpointConfig endpoint) { + return Result.success(endpointConfigService.updateEndpoint(endpoint)); + } + + @DeleteMapping("/endpoints") + public Result deleteEndpoint(@RequestParam String id) { + endpointConfigService.removeById(id); + return Result.success(); + } + + @GetMapping("/scenes") + public Result> scenes() { + return Result.success(sceneBindingService.listVisible()); + } + + @PostMapping("/scenes") + public Result 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 updateScene(@RequestBody AiSceneBinding scene) { + sceneBindingService.updateById(scene); + return Result.success(sceneBindingService.getById(scene.getId())); + } + + @DeleteMapping("/scenes") + public Result deleteScene(@RequestParam String id) { + sceneBindingService.removeById(id); + return Result.success(); + } + + @GetMapping("/call-logs") + public Result> callLogs(@RequestParam(required = false) Integer limit) { + return Result.success(callLogService.latest(limit)); + } + + @PostMapping("/runtime/test") + public Result 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); + } + } +} diff --git a/backend-single/src/main/java/com/emotion/controller/AsrController.java b/backend-single/src/main/java/com/emotion/controller/AsrController.java new file mode 100644 index 0000000..7334a5f --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/AsrController.java @@ -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 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()); + } + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java b/backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java new file mode 100644 index 0000000..2be2645 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java @@ -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 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; + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/ai/AiRuntimeTestResponse.java b/backend-single/src/main/java/com/emotion/dto/response/ai/AiRuntimeTestResponse.java new file mode 100644 index 0000000..b1020f7 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ai/AiRuntimeTestResponse.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/ai/AiStreamEvent.java b/backend-single/src/main/java/com/emotion/dto/response/ai/AiStreamEvent.java new file mode 100644 index 0000000..c84932a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ai/AiStreamEvent.java @@ -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 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 metadata) { + return AiStreamEvent.builder() + .type("done") + .message("stream completed") + .metadata(metadata == null ? Map.of() : metadata) + .build(); + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/asr/AsrTranscribeResponse.java b/backend-single/src/main/java/com/emotion/dto/response/asr/AsrTranscribeResponse.java new file mode 100644 index 0000000..2f9254a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/asr/AsrTranscribeResponse.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/AiCallLog.java b/backend-single/src/main/java/com/emotion/entity/AiCallLog.java new file mode 100644 index 0000000..dc54043 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/AiCallLog.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/AiEndpointConfig.java b/backend-single/src/main/java/com/emotion/entity/AiEndpointConfig.java new file mode 100644 index 0000000..c3b7971 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/AiEndpointConfig.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/AiProvider.java b/backend-single/src/main/java/com/emotion/entity/AiProvider.java new file mode 100644 index 0000000..d6e4009 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/AiProvider.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/AiSceneBinding.java b/backend-single/src/main/java/com/emotion/entity/AiSceneBinding.java new file mode 100644 index 0000000..34c5462 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/AiSceneBinding.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/mapper/AiCallLogMapper.java b/backend-single/src/main/java/com/emotion/mapper/AiCallLogMapper.java new file mode 100644 index 0000000..79ba76a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/AiCallLogMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/AiEndpointConfigMapper.java b/backend-single/src/main/java/com/emotion/mapper/AiEndpointConfigMapper.java new file mode 100644 index 0000000..18bf5c7 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/AiEndpointConfigMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/AiProviderMapper.java b/backend-single/src/main/java/com/emotion/mapper/AiProviderMapper.java new file mode 100644 index 0000000..252cbe8 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/AiProviderMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/AiSceneBindingMapper.java b/backend-single/src/main/java/com/emotion/mapper/AiSceneBindingMapper.java new file mode 100644 index 0000000..b428233 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/AiSceneBindingMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/service/AiCallLogService.java b/backend-single/src/main/java/com/emotion/service/AiCallLogService.java new file mode 100644 index 0000000..1268348 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AiCallLogService.java @@ -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 { + + List latest(Integer limit); +} diff --git a/backend-single/src/main/java/com/emotion/service/AiEndpointConfigService.java b/backend-single/src/main/java/com/emotion/service/AiEndpointConfigService.java new file mode 100644 index 0000000..5da73f4 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AiEndpointConfigService.java @@ -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 { + + List listVisible(); + + AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint); + + AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint); + + AiEndpointConfig getEnabledById(String id); +} diff --git a/backend-single/src/main/java/com/emotion/service/AiProviderService.java b/backend-single/src/main/java/com/emotion/service/AiProviderService.java new file mode 100644 index 0000000..6b9f2cc --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AiProviderService.java @@ -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 { + + List listVisible(); + + AiProvider saveProvider(AiProvider provider); + + AiProvider updateProvider(AiProvider provider); + + AiProvider getEnabledById(String id); +} diff --git a/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java b/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java new file mode 100644 index 0000000..0fa1b15 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java @@ -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 consumer); + + AiRuntimeTestResponse test(AiRuntimeRequest request); +} diff --git a/backend-single/src/main/java/com/emotion/service/AiSceneBindingService.java b/backend-single/src/main/java/com/emotion/service/AiSceneBindingService.java new file mode 100644 index 0000000..612ffd7 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AiSceneBindingService.java @@ -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 { + + List listVisible(); + + AiSceneBinding resolveScene(String sceneCode); +} diff --git a/backend-single/src/main/java/com/emotion/service/AsrService.java b/backend-single/src/main/java/com/emotion/service/AsrService.java new file mode 100644 index 0000000..f086279 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AsrService.java @@ -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); +} diff --git a/backend-single/src/main/java/com/emotion/service/ai/AiProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/AiProviderAdapter.java new file mode 100644 index 0000000..52fc37d --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ai/AiProviderAdapter.java @@ -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 consumer); +} diff --git a/backend-single/src/main/java/com/emotion/service/ai/AiTemplateRenderer.java b/backend-single/src/main/java/com/emotion/service/ai/AiTemplateRenderer.java new file mode 100644 index 0000000..eb6caff --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ai/AiTemplateRenderer.java @@ -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 mergeInputs(String defaultInputs, Map runtimeInputs) { + Map 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 inputs) { + if (!StringUtils.hasText(template)) { + return new JSONObject(); + } + String rendered = template; + for (Map.Entry 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 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 ""; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java new file mode 100644 index 0000000..ff3369e --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java @@ -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 consumer) { + Map 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 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; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java new file mode 100644 index 0000000..e7d3ecd --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java @@ -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 consumer) { + Map 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 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; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java b/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java new file mode 100644 index 0000000..49c6446 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java @@ -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 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 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; + } + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java new file mode 100644 index 0000000..ca92f5a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java @@ -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 implements AiCallLogService { + + @Override + public List latest(Integer limit) { + int size = limit == null ? 50 : Math.max(1, Math.min(limit, 200)); + return list(new LambdaQueryWrapper() + .eq(AiCallLog::getIsDeleted, 0) + .orderByDesc(AiCallLog::getCreateTime) + .last("limit " + size)); + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiChatServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiChatServiceImpl.java index e5e95af..75902de 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/AiChatServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/AiChatServiceImpl.java @@ -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 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 parameters = new HashMap<>(); - parameters.put("input", userMessage); - parameters.put("user_id", userId); - - // 4. 构建工作流请求体 - Map 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; } -} \ No newline at end of file +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiEndpointConfigServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiEndpointConfigServiceImpl.java new file mode 100644 index 0000000..328be76 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AiEndpointConfigServiceImpl.java @@ -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 implements AiEndpointConfigService { + + @Override + public List listVisible() { + return list(new LambdaQueryWrapper() + .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); + } + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiProviderServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiProviderServiceImpl.java new file mode 100644 index 0000000..050ba4b --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AiProviderServiceImpl.java @@ -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 implements AiProviderService { + + @Override + public List listVisible() { + return list(new LambdaQueryWrapper() + .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; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiSceneBindingServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiSceneBindingServiceImpl.java new file mode 100644 index 0000000..17db92d --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AiSceneBindingServiceImpl.java @@ -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 implements AiSceneBindingService { + + @Override + public List listVisible() { + return list(new LambdaQueryWrapper() + .eq(AiSceneBinding::getIsDeleted, 0) + .orderByDesc(AiSceneBinding::getPriority) + .orderByDesc(AiSceneBinding::getCreateTime)); + } + + @Override + public AiSceneBinding resolveScene(String sceneCode) { + return getOne(new LambdaQueryWrapper() + .eq(AiSceneBinding::getSceneCode, sceneCode) + .eq(AiSceneBinding::getIsEnabled, 1) + .eq(AiSceneBinding::getIsDeleted, 0) + .orderByDesc(AiSceneBinding::getPriority) + .last("limit 1")); + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AsrServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AsrServiceImpl.java new file mode 100644 index 0000000..406cf9d --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AsrServiceImpl.java @@ -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 body = new LinkedMultiValueMap<>(); + body.add("file", resource); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity 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 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; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java index 97cbc2f..547e0ee 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java @@ -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 plotJson = script.getPlotJson(); @@ -278,8 +282,7 @@ public class EpicScriptServiceImpl extends ServiceImpl 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 getPageByCurrentUser(LifeEventPageRequest request) { @@ -158,10 +156,11 @@ public class LifeEventServiceImpl extends ServiceImpl { + 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(); } -} \ No newline at end of file +} diff --git a/backend-single/src/main/resources/application-prod.yml b/backend-single/src/main/resources/application-prod.yml index e6cef5a..9f0fd54 100644 --- a/backend-single/src/main/resources/application-prod.yml +++ b/backend-single/src/main/resources/application-prod.yml @@ -5,6 +5,11 @@ server: port: 19089 spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + # 数据库配置 - 生产MySQL datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -64,6 +69,13 @@ emotion: public-url-prefix: /tts/audio max-text-length: 5000 default-voice: default_zh_female + + # Speech-to-text config + asr: + enabled: true + engine-url: http://127.0.0.1:19120 + max-file-size: 10485760 + allowed-types: wav,mp3,m4a,mp4,aac,amr # 生产模式配置 prod: diff --git a/backend-single/src/main/resources/application.yml b/backend-single/src/main/resources/application.yml index cd85bfc..60ed752 100644 --- a/backend-single/src/main/resources/application.yml +++ b/backend-single/src/main/resources/application.yml @@ -7,6 +7,11 @@ spring: application: name: emotion-single + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + profiles: active: ${SPRING_PROFILES_ACTIVE:local} @@ -102,6 +107,13 @@ emotion: max-text-length: 5000 default-voice: default_zh_female + # Speech-to-text config + asr: + enabled: true + engine-url: http://127.0.0.1:19120 + max-file-size: 10485760 + allowed-types: wav,mp3,m4a,mp4,aac,amr + # 安全配置 security: ignore-urls: diff --git a/backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java b/backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java index 202bee1..91902fa 100644 --- a/backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java +++ b/backend-single/src/test/java/com/emotion/controller/AuthControllerTest.java @@ -1,9 +1,13 @@ package com.emotion.controller; import com.emotion.service.AuthService; +import com.emotion.service.TokenService; +import com.emotion.util.JwtUtil; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; @@ -17,7 +21,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * @author huazhongmin * @date 2025-07-26 */ -@WebMvcTest(AuthController.class) +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) public class AuthControllerTest { @Autowired @@ -26,13 +31,28 @@ public class AuthControllerTest { @MockBean private AuthService authService; + @MockBean + private JwtUtil jwtUtil; + + @MockBean + private TokenService tokenService; + + @BeforeEach + public void setUp() { + when(jwtUtil.validateToken("test-token")).thenReturn(true); + when(jwtUtil.getUserIdFromToken("test-token")).thenReturn("test-user"); + when(jwtUtil.getUsernameFromToken("test-token")).thenReturn("tester"); + when(jwtUtil.getUserTypeFromToken("test-token")).thenReturn("user"); + } + @Test public void testCheckAccountExists() throws Exception { // 模拟账户存在的情况 when(authService.existsByAccount("existingUser")).thenReturn(true); - mockMvc.perform(get("/auth/check-account") - .param("account", "existingUser")) + mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api") + .param("account", "existingUser") + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(true)); @@ -43,8 +63,9 @@ public class AuthControllerTest { // 模拟账户不存在的情况 when(authService.existsByAccount("nonExistingUser")).thenReturn(false); - mockMvc.perform(get("/auth/check-account") - .param("account", "nonExistingUser")) + mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api") + .param("account", "nonExistingUser") + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(false)); @@ -55,8 +76,9 @@ public class AuthControllerTest { // 模拟邮箱存在的情况 when(authService.existsByEmail("existing@example.com")).thenReturn(true); - mockMvc.perform(get("/auth/check-email") - .param("email", "existing@example.com")) + mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api") + .param("email", "existing@example.com") + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(true)); @@ -67,8 +89,9 @@ public class AuthControllerTest { // 模拟邮箱不存在的情况 when(authService.existsByEmail("nonexisting@example.com")).thenReturn(false); - mockMvc.perform(get("/auth/check-email") - .param("email", "nonexisting@example.com")) + mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api") + .param("email", "nonexisting@example.com") + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(false)); @@ -79,8 +102,9 @@ public class AuthControllerTest { // 模拟手机号存在的情况 when(authService.existsByPhone("13800138000")).thenReturn(true); - mockMvc.perform(get("/auth/check-phone") - .param("phone", "13800138000")) + mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api") + .param("phone", "13800138000") + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(true)); @@ -91,8 +115,9 @@ public class AuthControllerTest { // 模拟手机号不存在的情况 when(authService.existsByPhone("13900139000")).thenReturn(false); - mockMvc.perform(get("/auth/check-phone") - .param("phone", "13900139000")) + mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api") + .param("phone", "13900139000") + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(false)); diff --git a/backend-single/src/test/java/com/emotion/service/CozeWorkflowIntegrationTest.java b/backend-single/src/test/java/com/emotion/service/CozeWorkflowIntegrationTest.java index e956630..d734d21 100644 --- a/backend-single/src/test/java/com/emotion/service/CozeWorkflowIntegrationTest.java +++ b/backend-single/src/test/java/com/emotion/service/CozeWorkflowIntegrationTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.RepeatedTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.AopTestUtils; import java.lang.reflect.Method; import java.util.HashMap; @@ -35,6 +36,8 @@ public class CozeWorkflowIntegrationTest { @Autowired private AiChatService aiChatService; + + private AiChatServiceImpl aiChatServiceImpl; @Autowired private AiConfigService aiConfigService; @@ -49,6 +52,7 @@ public class CozeWorkflowIntegrationTest { @BeforeEach public void setUp() { random = new Random(); + aiChatServiceImpl = AopTestUtils.getTargetObject(aiChatService); } // ==================== Property 1: Request Format Correctness ==================== @@ -82,7 +86,7 @@ public class CozeWorkflowIntegrationTest { @SuppressWarnings("unchecked") Map requestBody = (Map) buildWorkflowRequestMethod.invoke( - aiChatService, config, parameters, userId); + aiChatServiceImpl, config, parameters, userId); // 验证必需字段 // 2.1: workflow_id - 应该与数据库配置一致 @@ -155,7 +159,7 @@ public class CozeWorkflowIntegrationTest { parseMethod.setAccessible(true); java.util.stream.Stream lines = sseResponse.lines(); - String result = (String) parseMethod.invoke(aiChatService, lines); + String result = (String) parseMethod.invoke(aiChatServiceImpl, lines); // 验证正确提取output内容 assertEquals("这是AI生成的内容", result, @@ -184,7 +188,7 @@ public class CozeWorkflowIntegrationTest { parseMethod.setAccessible(true); java.util.stream.Stream lines = sseResponse.lines(); - String result = (String) parseMethod.invoke(aiChatService, lines); + String result = (String) parseMethod.invoke(aiChatServiceImpl, lines); // 验证正确提取随机output内容 assertEquals(randomOutput, result, @@ -218,7 +222,7 @@ public class CozeWorkflowIntegrationTest { parseMethod.setAccessible(true); java.util.stream.Stream lines = sseResponse.lines(); - String result = (String) parseMethod.invoke(aiChatService, lines); + String result = (String) parseMethod.invoke(aiChatServiceImpl, lines); // 验证只提取End节点的内容 assertEquals("最终输出内容", result, @@ -244,7 +248,7 @@ public class CozeWorkflowIntegrationTest { parseMethod.setAccessible(true); java.util.stream.Stream lines = sseResponse.lines(); - String result = (String) parseMethod.invoke(aiChatService, lines); + String result = (String) parseMethod.invoke(aiChatServiceImpl, lines); // 当content不是JSON或没有output字段时,应返回原始content assertEquals("直接内容,没有output字段", result, @@ -276,7 +280,7 @@ public class CozeWorkflowIntegrationTest { @SuppressWarnings("unchecked") Map mergedParams = (Map) mergeMethod.invoke( - aiChatService, config, runtimeParams); + aiChatServiceImpl, config, runtimeParams); // 验证运行时参数被正确设置 assertEquals(runtimeInput, mergedParams.get("input"), @@ -295,7 +299,7 @@ public class CozeWorkflowIntegrationTest { extractMethod.setAccessible(true); String content = "{\"output\":\"提取的内容\"}"; - String result = (String) extractMethod.invoke(aiChatService, content); + String result = (String) extractMethod.invoke(aiChatServiceImpl, content); assertEquals("提取的内容", result, "应正确提取output字段"); } @@ -308,7 +312,7 @@ public class CozeWorkflowIntegrationTest { extractMethod.setAccessible(true); String content = "这不是JSON内容"; - String result = (String) extractMethod.invoke(aiChatService, content); + String result = (String) extractMethod.invoke(aiChatServiceImpl, content); assertEquals("这不是JSON内容", result, "非JSON内容应原样返回"); } @@ -324,7 +328,7 @@ public class CozeWorkflowIntegrationTest { String randomOutput = "随机输出_" + UUID.randomUUID().toString(); String content = "{\"output\":\"" + randomOutput + "\"}"; - String result = (String) extractMethod.invoke(aiChatService, content); + String result = (String) extractMethod.invoke(aiChatServiceImpl, content); assertEquals(randomOutput, result, "应正确提取随机生成的output内容"); } diff --git a/docs/dify平台接口.md b/docs/dify平台接口.md new file mode 100644 index 0000000..f391f63 --- /dev/null +++ b/docs/dify平台接口.md @@ -0,0 +1,878 @@ +# Dify 平台接口文档 + +> 内部接口文档:本文包含可用 API Key,请勿提交到公开仓库或外部分享。 + +## 1. 基本信息 + +| 项 | 值 | +| --- | --- | +| 服务器端地址 | `http://49.232.138.53/v1` | +| 鉴权方式 | `Authorization: Bearer {API_KEY}` | +| API Key | `app-MqQOx09gCu9zzlKMpeLqHQHv` | +| 适用应用 | 短片小说生成、剧本生成 | + +## 2. 通用鉴权 + +Service API 使用 API-Key 进行鉴权。所有 API 请求都应在 `Authorization` HTTP Header 中携带 API Key。 + +```http +Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv +``` + +建议只在后端服务中保存和调用 API Key,避免将 Key 暴露到小程序、Web 前端或其它客户端。 + +## 3. 接口总览 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `POST` | `/chat-messages` | 发送对话消息 | +| `POST` | `/files/upload` | 上传文件 | +| `GET` | `/files/:file_id/preview` | 文件预览 | +| `POST` | `/chat-messages/:task_id/stop` | 停止响应 | +| `POST` | `/messages/:message_id/feedbacks` | 消息反馈 | +| `GET` | `/app/feedbacks` | 获取 App 的消息点赞和反馈 | +| `GET` | `/messages/{message_id}/suggested` | 获取下一轮建议问题列表 | +| `GET` | `/messages` | 获取会话历史消息 | +| `GET` | `/conversations` | 获取会话列表 | +| `DELETE` | `/conversations/:conversation_id` | 删除会话 | +| `POST` | `/conversations/:conversation_id/name` | 会话重命名 | +| `GET` | `/conversations/:conversation_id/variables` | 获取对话变量 | +| `PUT` | `/conversations/:conversation_id/variables/:variable_id` | 更新对话变量 | +| `POST` | `/audio-to-text` | 语音转文字 | +| `POST` | `/text-to-audio` | 文字转语音 | +| `GET` | `/info` | 获取应用基本信息 | +| `GET` | `/parameters` | 获取应用参数 | +| `GET` | `/meta` | 获取应用 Meta 信息 | +| `GET` | `/site` | 获取应用 WebApp 设置 | +| `GET` | `/apps/annotations` | 获取标注列表 | +| `POST` | `/apps/annotations` | 创建标注 | +| `PUT` | `/apps/annotations/{annotation_id}` | 更新标注 | +| `DELETE` | `/apps/annotations/{annotation_id}` | 删除标注 | +| `POST` | `/apps/annotation-reply/{action}` | 标注回复初始设置 | +| `GET` | `/apps/annotation-reply/{action}/status/{job_id}` | 查询标注回复初始设置任务状态 | + +## 4. 应用说明 + +### 4.1 短片小说生成 + +短片小说生成使用 Dify 对话型应用 API。接口路径、鉴权方式、请求参数和响应结构与本文后续章节一致。 + +### 4.2 剧本生成 + +剧本生成使用 Dify 工作流编排对话型应用 API。对话应用支持会话持久化,可将之前的聊天记录作为上下文继续回答,适用于聊天、客服 AI、脚本生成等场景。 + +## 5. 对话接口 + +### 5.1 发送对话消息 + +`POST /chat-messages` + +创建会话消息。 + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `query` | `string` | 是 | 用户输入或提问内容。 | +| `inputs` | `object` | 否 | App 定义的变量值,默认为 `{}`。如果变量是文件类型,请按 `files` 中的文件结构传入。 | +| `response_mode` | `string` | 是 | 响应模式:`streaming` 为流式模式,`blocking` 为阻塞模式。推荐使用 `streaming`。 | +| `user` | `string` | 是 | 终端用户标识。由开发者定义,需保证在应用内唯一。 | +| `conversation_id` | `string` | 否 | 会话 ID。继续历史会话时传入之前响应中的 `conversation_id`。 | +| `files` | `array` | 否 | 文件列表。适用于模型支持 Vision/Video 能力时的图文、多模态理解。 | +| `auto_generate_name` | `bool` | 否 | 是否自动生成会话标题,默认 `true`。 | +| `workflow_id` | `string` | 否 | 工作流 ID。用于指定特定已发布版本,不传则使用默认已发布版本。 | +| `trace_id` | `string` | 否 | 链路追踪 ID。也可通过 Header `X-Trace-Id` 或查询参数 `trace_id` 传递。优先级:Header > Query > Body。 | + +#### `files` 参数结构 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `type` | `string` | 文件类型,支持 `document`、`image`、`audio`、`video`、`custom`。 | +| `transfer_method` | `string` | 文件传递方式:`remote_url` 或 `local_file`。 | +| `url` | `string` | 文件地址。仅当 `transfer_method` 为 `remote_url` 时使用。 | +| `upload_file_id` | `string` | 上传文件 ID。仅当 `transfer_method` 为 `local_file` 时使用。 | + +文件类型支持范围: + +| 类型 | 支持格式 | +| --- | --- | +| `document` | `TXT`、`MD`、`MARKDOWN`、`MDX`、`PDF`、`HTML`、`XLSX`、`XLS`、`VTT`、`PROPERTIES`、`DOC`、`DOCX`、`CSV`、`EML`、`MSG`、`PPTX`、`PPT`、`XML`、`EPUB` | +| `image` | `JPG`、`JPEG`、`PNG`、`GIF`、`WEBP`、`SVG` | +| `audio` | `MP3`、`M4A`、`WAV`、`WEBM`、`MPGA` | +| `video` | `MP4`、`MOV`、`MPEG`、`WEBM` | +| `custom` | 其它文件类型 | + +#### 响应说明 + +当 `response_mode` 为 `blocking` 时,返回 `ChatCompletionResponse`。当 `response_mode` 为 `streaming` 时,返回 `ChunkChatCompletionResponse` 流式序列。 + +##### ChatCompletionResponse + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event` | `string` | 事件类型,固定为 `message`。 | +| `task_id` | `string` | 任务 ID,可用于停止响应接口。 | +| `id` | `string` | 唯一 ID。 | +| `message_id` | `string` | 消息唯一 ID。 | +| `conversation_id` | `string` | 会话 ID。 | +| `mode` | `string` | App 模式,固定为 `chat`。 | +| `answer` | `string` | 完整回复内容。 | +| `metadata` | `object` | 元数据。 | +| `usage` | `Usage` | 模型用量信息。 | +| `retriever_resources` | `array` | 引用和归属分段列表。 | +| `created_at` | `int` | 消息创建时间戳,例如 `1705395332`。 | + +##### ChunkChatCompletionResponse + +流式响应的 `Content-Type` 为 `text/event-stream`。每个流式块以 `data:` 开头,块之间使用两个换行符分隔。 + +常见事件: + +| 事件 | 说明 | +| --- | --- | +| `message` | LLM 文本块事件。 | +| `message_file` | 文件事件,表示有新文件需要展示。 | +| `message_end` | 消息结束事件,收到后表示流式返回结束。 | +| `tts_message` | TTS 音频流事件。 | +| `tts_message_end` | TTS 音频流结束事件。 | +| `message_replace` | 消息内容替换事件,通常用于内容审查命中后的预设回复。 | +| `workflow_started` | 工作流开始执行。 | +| `node_started` | 工作流节点开始执行。 | +| `node_finished` | 工作流节点执行结束。 | +| `workflow_finished` | 工作流执行结束。 | +| `error` | 流式输出过程中出现异常。 | +| `ping` | 每 10 秒一次的连接保活事件。 | + +#### 请求示例 + +```bash +curl -X POST 'http://49.232.138.53/v1/chat-messages' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "inputs": {}, + "query": "What are the specs of the iPhone 13 Pro Max?", + "response_mode": "streaming", + "conversation_id": "", + "user": "abc-123", + "files": [ + { + "type": "image", + "transfer_method": "remote_url", + "url": "https://cloud.dify.ai/logo/logo-site.png" + } + ] + }' +``` + +#### 阻塞模式响应示例 + +```json +{ + "event": "message", + "task_id": "c3800678-a077-43df-a102-53f23ed20b88", + "id": "9da23599-e713-473b-982c-4328d4f5c78a", + "message_id": "9da23599-e713-473b-982c-4328d4f5c78a", + "conversation_id": "45701982-8118-4bc5-8e9b-64562b4555f2", + "mode": "chat", + "answer": "iPhone 13 Pro Max specs are listed here:...", + "metadata": { + "usage": { + "prompt_tokens": 1033, + "completion_tokens": 128, + "total_tokens": 1161, + "total_price": "0.0012890", + "currency": "USD", + "latency": 0.7682376249867957 + }, + "retriever_resources": [] + }, + "created_at": 1705407629 +} +``` + +#### 流式模式响应示例 + +```text +data: {"event":"message","message_id":"5ad4cb98-f0c7-4085-b384-88c403be6290","conversation_id":"45701982-8118-4bc5-8e9b-64562b4555f2","answer":"I","created_at":1679586595} + +data: {"event":"message_end","conversation_id":"45701982-8118-4bc5-8e9b-64562b4555f2","metadata":{"usage":{"prompt_tokens":1033,"completion_tokens":135,"total_tokens":1168}}} +``` + +#### 错误码 + +| HTTP 状态码 | 错误码 | 说明 | +| --- | --- | --- | +| `400` | `invalid_param` | 传入参数异常。 | +| `400` | `app_unavailable` | App 配置不可用。 | +| `400` | `provider_not_initialize` | 无可用模型凭据配置。 | +| `400` | `provider_quota_exceeded` | 模型调用额度不足。 | +| `400` | `model_currently_not_support` | 当前模型不可用。 | +| `400` | `workflow_not_found` | 指定的工作流版本未找到。 | +| `400` | `draft_workflow_error` | 无法使用草稿工作流版本。 | +| `400` | `workflow_id_format_error` | 工作流 ID 格式错误,需要 UUID 格式。 | +| `400` | `completion_request_error` | 文本生成失败。 | +| `404` | - | 对话不存在。 | +| `500` | - | 服务内部异常。 | + +### 5.2 停止响应 + +`POST /chat-messages/:task_id/stop` + +停止正在进行的流式响应。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `task_id` | `string` | 任务 ID,由发送对话消息接口返回。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user` | `string` | 是 | 终端用户标识,必须与发送消息时的 `user` 一致。 | + +#### 请求示例 + +```bash +curl -X POST 'http://49.232.138.53/v1/chat-messages/:task_id/stop' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "user": "abc-123" + }' +``` + +#### 响应示例 + +```json +{ + "result": "success" +} +``` + +## 6. 文件接口 + +### 6.1 上传文件 + +`POST /files/upload` + +上传文件并在发送消息时使用,可实现图文多模态理解。上传文件仅供当前终端用户使用。 + +#### 请求体参数 + +该接口使用 `multipart/form-data` 请求。 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file` | `file` | 是 | 要上传的文件。 | +| `user` | `string` | 是 | 终端用户标识,需和发送消息接口的 `user` 保持一致。 | + +#### 请求示例 + +```bash +curl -X POST 'http://49.232.138.53/v1/files/upload' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --form 'file=@localfile;type=image/png' \ + --form 'user=abc-123' +``` + +#### 响应字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | `string` | 文件 ID。 | +| `name` | `string` | 文件名。 | +| `size` | `int` | 文件大小,单位为字节。 | +| `extension` | `string` | 文件扩展名。 | +| `mime_type` | `string` | MIME 类型。 | +| `created_by` | `string` | 上传用户。 | +| `created_at` | `int` | 创建时间戳。 | + +### 6.2 文件预览 + +`GET /files/:file_id/preview` + +预览或下载已上传文件。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `file_id` | `string` | 文件 ID。 | + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `as_attachment` | `bool` | 否 | 是否作为附件下载。传 `true` 时触发下载。 | + +#### 请求示例 + +```bash +curl -X GET 'http://49.232.138.53/v1/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +#### 下载示例 + +```bash +curl -X GET 'http://49.232.138.53/v1/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +## 7. 消息与反馈接口 + +### 7.1 消息反馈 + +`POST /messages/:message_id/feedbacks` + +提交消息点赞、点踩或文字反馈。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `message_id` | `string` | 消息 ID。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `rating` | `string` | 否 | 反馈类型,例如 `like`、`dislike`。传 `null` 可取消反馈。 | +| `user` | `string` | 是 | 终端用户标识。 | +| `content` | `string` | 否 | 消息反馈的具体内容。 | + +#### 请求示例 + +```bash +curl -X POST 'http://49.232.138.53/v1/messages/:message_id/feedbacks' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "rating": "like", + "user": "abc-123", + "content": "message feedback information" + }' +``` + +#### 响应示例 + +```json +{ + "result": "success" +} +``` + +### 7.2 获取 App 的消息点赞和反馈 + +`GET /app/feedbacks` + +获取应用终端用户的反馈和点赞记录。 + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `page` | `string` | 否 | 分页页码,默认 `1`。 | +| `limit` | `string` | 否 | 每页数量,默认 `20`。 | + +#### 请求示例 + +```bash +curl -X GET 'http://49.232.138.53/v1/app/feedbacks?page=1&limit=20' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 7.3 获取下一轮建议问题列表 + +`GET /messages/{message_id}/suggested` + +获取指定消息的下一轮建议问题列表。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `message_id` | `string` | 消息 ID。 | + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user` | `string` | 是 | 终端用户标识。 | + +#### 请求示例 + +```bash +curl --location --request GET 'http://49.232.138.53/v1/messages/{message_id}/suggested?user=abc-123' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 7.4 获取会话历史消息 + +`GET /messages` + +获取指定会话的历史消息。 + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user` | `string` | 是 | 终端用户标识。 | +| `conversation_id` | `string` | 是 | 会话 ID。 | +| `first_id` | `string` | 否 | 当前页第一条聊天记录 ID,用于分页。 | +| `limit` | `int` | 否 | 一次请求返回多少条记录,默认 `20`。 | + +#### 请求示例 + +```bash +curl -X GET 'http://49.232.138.53/v1/messages?user=abc-123&conversation_id={conversation_id}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +## 8. 会话接口 + +### 8.1 获取会话列表 + +`GET /conversations` + +获取当前用户的会话列表。 + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user` | `string` | 是 | 终端用户标识。 | +| `last_id` | `string` | 否 | 当前页最后一条记录 ID,用于分页。 | +| `limit` | `int` | 否 | 一次请求返回多少条记录,默认 `20`。 | +| `sort_by` | `string` | 否 | 排序字段,例如 `created_at`、`updated_at`。 | + +#### 请求示例 + +```bash +curl -X GET 'http://49.232.138.53/v1/conversations?user=abc-123&last_id=&limit=20' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 8.2 删除会话 + +`DELETE /conversations/:conversation_id` + +删除指定会话。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `conversation_id` | `string` | 会话 ID。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user` | `string` | 是 | 终端用户标识。 | + +#### 请求示例 + +```bash +curl -X DELETE 'http://49.232.138.53/v1/conversations/{conversation_id}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "user": "abc-123" + }' +``` + +### 8.3 会话重命名 + +`POST /conversations/:conversation_id/name` + +重命名指定会话。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `conversation_id` | `string` | 会话 ID。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `name` | `string` | 否 | 新会话名称。 | +| `auto_generate` | `bool` | 否 | 是否自动生成标题。 | +| `user` | `string` | 是 | 终端用户标识。 | + +#### 请求示例 + +```bash +curl -X POST 'http://49.232.138.53/v1/conversations/{conversation_id}/name' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "name": "新的会话名称", + "auto_generate": false, + "user": "abc-123" + }' +``` + +### 8.4 获取对话变量 + +`GET /conversations/:conversation_id/variables` + +获取指定会话中的变量列表。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `conversation_id` | `string` | 会话 ID。 | + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user` | `string` | 是 | 终端用户标识。 | +| `last_id` | `string` | 否 | 当前页最后一条记录 ID。 | +| `limit` | `int` | 否 | 每页数量。 | + +#### 请求示例 + +```bash +curl -X GET 'http://49.232.138.53/v1/conversations/{conversation_id}/variables?user=abc-123' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 8.5 更新对话变量 + +`PUT /conversations/:conversation_id/variables/:variable_id` + +更新指定会话变量。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `conversation_id` | `string` | 会话 ID。 | +| `variable_id` | `string` | 变量 ID。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `value` | `string` | 是 | 变量值。 | +| `user` | `string` | 是 | 终端用户标识。 | + +#### 请求示例 + +```bash +curl -X PUT 'http://49.232.138.53/v1/conversations/{conversation_id}/variables/{variable_id}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "value": "Updated Value", + "user": "abc-123" + }' +``` + +## 9. 音频接口 + +### 9.1 语音转文字 + +`POST /audio-to-text` + +将音频文件转写为文本。 + +#### 请求体参数 + +该接口使用 `multipart/form-data` 请求。 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file` | `file` | 是 | 音频文件。 | +| `user` | `string` | 是 | 终端用户标识。 | + +#### 请求示例 + +```bash +curl -X POST 'http://49.232.138.53/v1/audio-to-text' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --form 'file=@localfile;type=audio/mpeg' \ + --form 'user=abc-123' +``` + +### 9.2 文字转语音 + +`POST /text-to-audio` + +将文本或消息内容转换为音频。 + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `message_id` | `string` | 否 | 消息 ID。传入后对指定消息内容进行语音合成。 | +| `text` | `string` | 否 | 待转换文本。未传 `message_id` 时使用。 | +| `user` | `string` | 是 | 终端用户标识。 | + +#### 请求示例 + +```bash +curl -o text-to-audio.mp3 -X POST 'http://49.232.138.53/v1/text-to-audio' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "text": "hello", + "user": "abc-123" + }' +``` + +## 10. 应用信息接口 + +### 10.1 获取应用基本信息 + +`GET /info` + +获取应用基本信息。 + +```bash +curl -X GET 'http://49.232.138.53/v1/info' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 10.2 获取应用参数 + +`GET /parameters` + +获取应用输入参数、用户输入表单、文件上传配置、系统参数等。 + +```bash +curl -X GET 'http://49.232.138.53/v1/parameters' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 10.3 获取应用 Meta 信息 + +`GET /meta` + +获取应用 Meta 信息,例如工具图标等。 + +```bash +curl -X GET 'http://49.232.138.53/v1/meta' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 10.4 获取应用 WebApp 设置 + +`GET /site` + +获取应用 WebApp 设置。 + +```bash +curl -X GET 'http://49.232.138.53/v1/site' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +## 11. 标注接口 + +### 11.1 获取标注列表 + +`GET /apps/annotations` + +获取应用标注列表。 + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `page` | `string` | 否 | 页码,默认 `1`。 | +| `limit` | `string` | 否 | 每页数量,默认 `20`。 | + +#### 请求示例 + +```bash +curl --location --request GET 'http://49.232.138.53/v1/apps/annotations?page=1&limit=20' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 11.2 创建标注 + +`POST /apps/annotations` + +创建一条标注问答。 + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `question` | `string` | 是 | 问题。 | +| `answer` | `string` | 是 | 答案。 | + +#### 请求示例 + +```bash +curl --location --request POST 'http://49.232.138.53/v1/apps/annotations' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "question": "What is Dify?", + "answer": "Dify is an LLM application development platform." + }' +``` + +### 11.3 更新标注 + +`PUT /apps/annotations/{annotation_id}` + +更新指定标注。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `annotation_id` | `string` | 标注 ID。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `question` | `string` | 是 | 问题。 | +| `answer` | `string` | 是 | 答案。 | + +#### 请求示例 + +```bash +curl --location --request PUT 'http://49.232.138.53/v1/apps/annotations/{annotation_id}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "question": "What is Dify?", + "answer": "Dify is an LLM application development platform." + }' +``` + +### 11.4 删除标注 + +`DELETE /apps/annotations/{annotation_id}` + +删除指定标注。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `annotation_id` | `string` | 标注 ID。 | + +#### 请求示例 + +```bash +curl --location --request DELETE 'http://49.232.138.53/v1/apps/annotations/{annotation_id}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +### 11.5 标注回复初始设置 + +`POST /apps/annotation-reply/{action}` + +启用或禁用标注回复。该接口异步执行,返回 `job_id` 后可通过任务状态接口查询最终结果。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `action` | `string` | 动作,只能是 `enable` 或 `disable`。 | + +#### 请求体参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `score_threshold` | `number` | 是 | 相似度阈值。当相似度大于该阈值时,系统自动回复。 | +| `embedding_provider_name` | `string` | 是 | 嵌入模型提供商。 | +| `embedding_model_name` | `string` | 是 | 嵌入模型名称。 | + +#### 请求示例 + +```bash +curl --location --request POST 'http://49.232.138.53/v1/apps/annotation-reply/{action}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "score_threshold": 0.9, + "embedding_provider_name": "zhipu", + "embedding_model_name": "embedding_3" + }' +``` + +#### 响应示例 + +```json +{ + "job_id": "b15c8f68-1cf4-4877-bf21-ed7cf2011802", + "job_status": "waiting" +} +``` + +### 11.6 查询标注回复初始设置任务状态 + +`GET /apps/annotation-reply/{action}/status/{job_id}` + +查询标注回复启用或禁用任务状态。 + +#### 路径参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `action` | `string` | 动作,只能是 `enable` 或 `disable`,并且必须和标注回复初始设置接口的动作一致。 | +| `job_id` | `string` | 任务 ID,由标注回复初始设置接口返回。 | + +#### 请求示例 + +```bash +curl --location --request GET 'http://49.232.138.53/v1/apps/annotation-reply/{action}/status/{job_id}' \ + --header 'Authorization: Bearer app-MqQOx09gCu9zzlKMpeLqHQHv' +``` + +#### 响应示例 + +```json +{ + "job_id": "b15c8f68-1cf4-4877-bf21-ed7cf2011802", + "job_status": "waiting", + "error_msg": "" +} +``` + +## 12. 通用响应对象 + +### 12.1 Usage + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `prompt_tokens` | `int` | 提示词 token 数。 | +| `prompt_unit_price` | `string` | 提示词单价。 | +| `prompt_price_unit` | `string` | 提示词价格单位。 | +| `prompt_price` | `string` | 提示词价格。 | +| `completion_tokens` | `int` | 补全 token 数。 | +| `completion_unit_price` | `string` | 补全单价。 | +| `completion_price_unit` | `string` | 补全价格单位。 | +| `completion_price` | `string` | 补全价格。 | +| `total_tokens` | `int` | 总 token 数。 | +| `total_price` | `string` | 总价格。 | +| `currency` | `string` | 货币。 | +| `latency` | `number` | 请求耗时。 | + +### 12.2 RetrieverResource + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `position` | `int` | 引用位置。 | +| `dataset_id` | `string` | 数据集 ID。 | +| `dataset_name` | `string` | 数据集名称。 | +| `document_id` | `string` | 文档 ID。 | +| `document_name` | `string` | 文档名称。 | +| `segment_id` | `string` | 分段 ID。 | +| `score` | `number` | 相关性分数。 | +| `content` | `string` | 分段内容。 | + diff --git a/docs/superpowers/plans/2026-05-21-mini-program-asr-service.md b/docs/superpowers/plans/2026-05-21-mini-program-asr-service.md new file mode 100644 index 0000000..47fc0e5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-mini-program-asr-service.md @@ -0,0 +1,64 @@ +# Mini Program ASR Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deploy a private Chinese ASR service on `101.200.208.45` and wire the mini program voice orb to transcribe speech into the wish input. + +**Architecture:** A local FastAPI ASR service listens on `127.0.0.1:19120` and uses FunASR/SenseVoiceSmall when available. The Java backend exposes `/api/asr/transcribe` as the authenticated upload proxy, and the mini program records audio with `uni.getRecorderManager()` then uploads it through the existing request service. + +**Tech Stack:** FunASR/SenseVoiceSmall, Python 3.11, FastAPI, Spring Boot multipart upload, uni-app recorder/upload APIs. + +--- + +### Task 1: ASR Service + +**Files:** +- Create: `backend-single/asr-service/app.py` +- Create: `backend-single/asr-service/requirements.txt` +- Create: `backend-single/asr-service/emotion-museum-asr.service` +- Create: `backend-single/asr-service/README.md` + +- [x] Add a FastAPI service with `/health` and `/transcribe`. +- [x] Accept an uploaded audio file, save it under `/tmp/emotion-museum-asr`, run the ASR model, and return JSON with `success`, `text`, `language`, `durationMs`, `engine`, and `errorMessage`. +- [x] Keep the service bound to `127.0.0.1:19120`. + +### Task 2: Java Backend Proxy + +**Files:** +- Create: `backend-single/src/main/java/com/emotion/dto/response/asr/AsrTranscribeResponse.java` +- Create: `backend-single/src/main/java/com/emotion/service/AsrService.java` +- Create: `backend-single/src/main/java/com/emotion/service/impl/AsrServiceImpl.java` +- Create: `backend-single/src/main/java/com/emotion/controller/AsrController.java` +- Modify: `backend-single/src/main/resources/application.yml` +- Modify: `backend-single/src/main/resources/application-prod.yml` + +- [x] Add `emotion.asr` config for `enabled`, `engine-url`, `max-file-size`, and `allowed-types`. +- [x] Validate upload presence, size, and suffix. +- [x] Forward multipart audio to the local ASR service. +- [x] Return a normal `Result` to the mini program. + +### Task 3: Mini Program Recording + +**Files:** +- Create: `mini-program/src/services/asr.js` +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Use `uni.getRecorderManager()` to start recording on press. +- [x] Stop recording on release/cancel. +- [x] Upload the recorded temp file to `/asr/transcribe`. +- [x] Fill `wishText` with recognized text and keep the current visual theme. +- [x] Track success/failure analytics. + +### Task 4: Deploy And Verify + +**Commands:** +- `npm run build:mp-weixin` +- `mvn -DskipTests package` +- Deploy backend jar to `101.200.208.45`. +- Install/start `emotion-museum-asr`. +- Check `curl http://127.0.0.1:19120/health`. + +**Acceptance:** +- Pressing and releasing the mini program voice orb sends audio to backend ASR. +- Recognized Chinese text appears in the wish input. +- Server ASR and TTS services both remain active. diff --git a/docs/superpowers/plans/2026-05-23-ai-config-test-fix.md b/docs/superpowers/plans/2026-05-23-ai-config-test-fix.md new file mode 100644 index 0000000..1d0abf6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ai-config-test-fix.md @@ -0,0 +1,226 @@ +# AI 配置管理接口测试修复实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 删除废弃的旧 AI 配置页面,在新系统测试对话框中增加非流式测试功能 + +**Architecture:** 路由已正确指向新系统 `AiRoutingList.vue`,只需删除旧文件残留并增强测试对话框。后端接口 `testAiRuntime` 已存在,只需在 API 层和 UI 层连接。 + +**Tech Stack:** Vue 3, Element Plus, TypeScript + +--- + +### 前置确认:检查生产数据库配置 + +- [ ] 在生产数据库上执行以下查询,确认 Dify provider 和 endpoint 状态 + +```sql +-- 检查 Dify provider +SELECT id, provider_code, provider_name, provider_type, base_url, is_enabled +FROM t_ai_provider WHERE provider_code = 'dify_default'; + +-- 检查 Dify endpoints +SELECT id, endpoint_code, endpoint_name, provider_id, api_path, support_stream, is_enabled +FROM t_ai_endpoint_config WHERE endpoint_code LIKE 'dify.%'; + +-- 检查场景绑定 +SELECT id, scene_code, scene_name, endpoint_id, is_enabled, required_stream +FROM t_ai_scene_binding WHERE is_enabled = 1; +``` + +- [ ] 如果 Dify provider `is_enabled = 0`,执行: +```sql +UPDATE t_ai_provider SET is_enabled = 1 WHERE provider_code = 'dify_default'; +``` + +- [ ] 如果 Dify endpoints `is_enabled = 0`,执行: +```sql +UPDATE t_ai_endpoint_config SET is_enabled = 1 WHERE endpoint_code LIKE 'dify.%'; +``` + +### Task 1: 删除废弃的旧 AI 配置页面 + +**Files:** +- Delete: `web-admin/src/views/aiconfig/AiConfigList.vue` + +- [ ] 删除旧页面文件 + +```bash +rm web-admin/src/views/aiconfig/AiConfigList.vue +``` + +### Task 2: 在 API 层导出非流式测试函数 + +**Files:** +- Modify: `web-admin/src/api/aiconfig.ts` + +- [ ] 确认 `testAiRuntime` 已存在(已确认在第 263 行) + +```typescript +export function testAiRuntime(data: AiRuntimeRequest) { + return request({ url: '/ai/runtime/test', method: 'post', data }) +} +``` + +已存在,无需修改。但需要确认 `AiRoutingList.vue` 中是否 import 了这个函数。 + +检查 `web-admin/src/views/aiconfig/AiRoutingList.vue` 的 import 语句(当前第 269-281 行): +- 当前 import 了 `streamAiRuntime`,**没有 import** `testAiRuntime` +- 需要在 import 列表中添加 `testAiRuntime` + +### Task 3: 在测试对话框中增加非流式测试按钮 + +**Files:** +- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue` + +- [ ] 在 import 中添加 `testAiRuntime` + +修改第 269-281 行的 import 语句: + +```typescript +import { + deleteAiEndpoint, + deleteAiProvider, + deleteAiScene, + listAiCallLogs, + listAiEndpoints, + listAiProviders, + listAiScenes, + saveAiEndpoint, + saveAiProvider, + saveAiScene, + streamAiRuntime, + testAiRuntime // ← 新增 +} from '@/api/aiconfig' +``` + +- [ ] 在测试对话框 footer 中增加「非流式测试」按钮 + +找到第 257-261 行的 `