feat: AI 场景路由、ASR 服务及前后端全链路同步
- 新增 AI 场景路由控制器和管理接口 - 新增 ASR 语音识别服务及前后端集成 - 同步 AI Runtime 客户端到 Web/小程序/Life-Script - 完善 AI 配置测试修复和管理后台路由配置 - 新增数据库迁移脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"owner process exited","timestamp":1779459252481}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package com.emotion.controller;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.emotion.common.Result;
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
|
import com.emotion.entity.AiCallLog;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
import com.emotion.entity.AiSceneBinding;
|
||||||
|
import com.emotion.service.AiCallLogService;
|
||||||
|
import com.emotion.service.AiEndpointConfigService;
|
||||||
|
import com.emotion.service.AiProviderService;
|
||||||
|
import com.emotion.service.AiRuntimeService;
|
||||||
|
import com.emotion.service.AiSceneBindingService;
|
||||||
|
import com.emotion.util.UserContextHolder;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/ai")
|
||||||
|
public class AiRoutingController {
|
||||||
|
|
||||||
|
private final AiProviderService providerService;
|
||||||
|
private final AiEndpointConfigService endpointConfigService;
|
||||||
|
private final AiSceneBindingService sceneBindingService;
|
||||||
|
private final AiCallLogService callLogService;
|
||||||
|
private final AiRuntimeService runtimeService;
|
||||||
|
|
||||||
|
public AiRoutingController(AiProviderService providerService,
|
||||||
|
AiEndpointConfigService endpointConfigService,
|
||||||
|
AiSceneBindingService sceneBindingService,
|
||||||
|
AiCallLogService callLogService,
|
||||||
|
AiRuntimeService runtimeService) {
|
||||||
|
this.providerService = providerService;
|
||||||
|
this.endpointConfigService = endpointConfigService;
|
||||||
|
this.sceneBindingService = sceneBindingService;
|
||||||
|
this.callLogService = callLogService;
|
||||||
|
this.runtimeService = runtimeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/providers")
|
||||||
|
public Result<List<AiProvider>> providers() {
|
||||||
|
return Result.success(providerService.listVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/providers")
|
||||||
|
public Result<AiProvider> createProvider(@RequestBody AiProvider provider) {
|
||||||
|
return Result.success(providerService.saveProvider(provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/providers")
|
||||||
|
public Result<AiProvider> updateProvider(@RequestBody AiProvider provider) {
|
||||||
|
return Result.success(providerService.updateProvider(provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/providers")
|
||||||
|
public Result<Void> deleteProvider(@RequestParam String id) {
|
||||||
|
providerService.removeById(id);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/endpoints")
|
||||||
|
public Result<List<AiEndpointConfig>> endpoints() {
|
||||||
|
return Result.success(endpointConfigService.listVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/endpoints")
|
||||||
|
public Result<AiEndpointConfig> createEndpoint(@RequestBody AiEndpointConfig endpoint) {
|
||||||
|
return Result.success(endpointConfigService.saveEndpoint(endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/endpoints")
|
||||||
|
public Result<AiEndpointConfig> updateEndpoint(@RequestBody AiEndpointConfig endpoint) {
|
||||||
|
return Result.success(endpointConfigService.updateEndpoint(endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/endpoints")
|
||||||
|
public Result<Void> deleteEndpoint(@RequestParam String id) {
|
||||||
|
endpointConfigService.removeById(id);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/scenes")
|
||||||
|
public Result<List<AiSceneBinding>> scenes() {
|
||||||
|
return Result.success(sceneBindingService.listVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/scenes")
|
||||||
|
public Result<AiSceneBinding> createScene(@RequestBody AiSceneBinding scene) {
|
||||||
|
if (scene.getIsEnabled() == null) {
|
||||||
|
scene.setIsEnabled(1);
|
||||||
|
}
|
||||||
|
if (scene.getRequiredStream() == null) {
|
||||||
|
scene.setRequiredStream(1);
|
||||||
|
}
|
||||||
|
sceneBindingService.save(scene);
|
||||||
|
return Result.success(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/scenes")
|
||||||
|
public Result<AiSceneBinding> updateScene(@RequestBody AiSceneBinding scene) {
|
||||||
|
sceneBindingService.updateById(scene);
|
||||||
|
return Result.success(sceneBindingService.getById(scene.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/scenes")
|
||||||
|
public Result<Void> deleteScene(@RequestParam String id) {
|
||||||
|
sceneBindingService.removeById(id);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/call-logs")
|
||||||
|
public Result<List<AiCallLog>> callLogs(@RequestParam(required = false) Integer limit) {
|
||||||
|
return Result.success(callLogService.latest(limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/runtime/test")
|
||||||
|
public Result<AiRuntimeTestResponse> runtimeTest(@RequestBody JSONObject payload) {
|
||||||
|
AiRuntimeRequest request = withCurrentUser(AiRuntimeRequest.fromPayload(payload));
|
||||||
|
return Result.success(runtimeService.test(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/runtime/stream")
|
||||||
|
public SseEmitter runtimeStream(@RequestBody JSONObject payload) {
|
||||||
|
AiRuntimeRequest request = withCurrentUser(AiRuntimeRequest.fromPayload(payload));
|
||||||
|
SseEmitter emitter = new SseEmitter(0L);
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
runtimeService.invokeStream(request, event -> sendEvent(emitter, event));
|
||||||
|
emitter.complete();
|
||||||
|
}).exceptionally(error -> {
|
||||||
|
sendEvent(emitter, AiStreamEvent.error("AI_STREAM_INTERRUPTED", error.getMessage()));
|
||||||
|
emitter.completeWithError(error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiRuntimeRequest withCurrentUser(AiRuntimeRequest request) {
|
||||||
|
request.setUserId(UserContextHolder.getCurrentUserId());
|
||||||
|
request.setUserName(UserContextHolder.getCurrentUsername());
|
||||||
|
request.setUserType(UserContextHolder.getCurrentUserType());
|
||||||
|
request.setRequestId(UserContextHolder.getRequestId());
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendEvent(SseEmitter emitter, AiStreamEvent event) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name(event.getType())
|
||||||
|
.data(event));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("AI stream client disconnected: {}", e.getMessage());
|
||||||
|
throw new IllegalStateException("AI_STREAM_CLIENT_DISCONNECTED", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.emotion.controller;
|
||||||
|
|
||||||
|
import com.emotion.common.Result;
|
||||||
|
import com.emotion.dto.response.asr.AsrTranscribeResponse;
|
||||||
|
import com.emotion.service.AsrService;
|
||||||
|
import com.emotion.util.UserContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/asr")
|
||||||
|
public class AsrController {
|
||||||
|
|
||||||
|
private final AsrService asrService;
|
||||||
|
|
||||||
|
public AsrController(AsrService asrService) {
|
||||||
|
this.asrService = asrService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/transcribe")
|
||||||
|
public Result<AsrTranscribeResponse> transcribe(@RequestPart("file") MultipartFile file) {
|
||||||
|
if (UserContextHolder.getCurrentUserId() == null) {
|
||||||
|
return Result.unauthorized();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Result.success(asrService.transcribe(file));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Result.badRequest(e.getMessage());
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return Result.error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.emotion.dto.request.ai;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class AiRuntimeRequest {
|
||||||
|
|
||||||
|
private static final Set<String> RESERVED_KEYS = Set.of("sceneCode", "scene", "inputs", "userId", "userName", "username", "userType", "requestId");
|
||||||
|
|
||||||
|
private String sceneCode;
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
private String userType;
|
||||||
|
|
||||||
|
private String requestId;
|
||||||
|
|
||||||
|
private JSONObject inputs = new JSONObject();
|
||||||
|
|
||||||
|
public static AiRuntimeRequest fromPayload(JSONObject payload) {
|
||||||
|
AiRuntimeRequest request = new AiRuntimeRequest();
|
||||||
|
if (payload == null) {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
String sceneCode = payload.getString("sceneCode");
|
||||||
|
if (!StringUtils.hasText(sceneCode)) {
|
||||||
|
sceneCode = payload.getString("scene");
|
||||||
|
}
|
||||||
|
request.setSceneCode(sceneCode);
|
||||||
|
|
||||||
|
JSONObject inputs = payload.getJSONObject("inputs");
|
||||||
|
if (inputs == null) {
|
||||||
|
inputs = new JSONObject();
|
||||||
|
JSONObject runtimeInputs = inputs;
|
||||||
|
payload.forEach((key, value) -> {
|
||||||
|
if (!RESERVED_KEYS.contains(key)) {
|
||||||
|
runtimeInputs.put(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request.setInputs(inputs);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.emotion.dto.response.ai;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class AiRuntimeTestResponse {
|
||||||
|
|
||||||
|
private String sceneCode;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private String output;
|
||||||
|
|
||||||
|
private Long durationMs;
|
||||||
|
|
||||||
|
private Integer streamChunks;
|
||||||
|
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.emotion.dto.response.ai;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AiStreamEvent {
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
private Integer seq;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private Long timestamp = System.currentTimeMillis();
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private Map<String, Object> metadata = new HashMap<>();
|
||||||
|
|
||||||
|
public static AiStreamEvent start(String sceneCode) {
|
||||||
|
return AiStreamEvent.builder()
|
||||||
|
.type("start")
|
||||||
|
.message("stream started")
|
||||||
|
.metadata(Map.of("sceneCode", sceneCode))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AiStreamEvent delta(String content, int seq) {
|
||||||
|
return AiStreamEvent.builder()
|
||||||
|
.type("delta")
|
||||||
|
.content(content)
|
||||||
|
.seq(seq)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AiStreamEvent error(String code, String message) {
|
||||||
|
return AiStreamEvent.builder()
|
||||||
|
.type("error")
|
||||||
|
.code(code)
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AiStreamEvent done(Map<String, Object> metadata) {
|
||||||
|
return AiStreamEvent.builder()
|
||||||
|
.type("done")
|
||||||
|
.message("stream completed")
|
||||||
|
.metadata(metadata == null ? Map.of() : metadata)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.emotion.dto.response.asr;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class AsrTranscribeResponse {
|
||||||
|
|
||||||
|
private String text;
|
||||||
|
private String language;
|
||||||
|
private Long durationMs;
|
||||||
|
private String engine;
|
||||||
|
private String model;
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.emotion.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.emotion.common.BaseEntity;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@SuperBuilder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@TableName("t_ai_call_log")
|
||||||
|
public class AiCallLog extends BaseEntity {
|
||||||
|
|
||||||
|
@TableField("scene_code")
|
||||||
|
private String sceneCode;
|
||||||
|
|
||||||
|
@TableField("provider_code")
|
||||||
|
private String providerCode;
|
||||||
|
|
||||||
|
@TableField("endpoint_code")
|
||||||
|
private String endpointCode;
|
||||||
|
|
||||||
|
@TableField("user_id")
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@TableField("request_id")
|
||||||
|
private String requestId;
|
||||||
|
|
||||||
|
@TableField("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@TableField("input_text")
|
||||||
|
private String inputText;
|
||||||
|
|
||||||
|
@TableField("output_text")
|
||||||
|
private String outputText;
|
||||||
|
|
||||||
|
@TableField("error_code")
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
@TableField("error_message")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@TableField("first_token_ms")
|
||||||
|
private Long firstTokenMs;
|
||||||
|
|
||||||
|
@TableField("duration_ms")
|
||||||
|
private Long durationMs;
|
||||||
|
|
||||||
|
@TableField("stream_chunks")
|
||||||
|
private Integer streamChunks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.emotion.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.emotion.common.BaseEntity;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@SuperBuilder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@TableName("t_ai_endpoint_config")
|
||||||
|
public class AiEndpointConfig extends BaseEntity {
|
||||||
|
|
||||||
|
@TableField("endpoint_code")
|
||||||
|
private String endpointCode;
|
||||||
|
|
||||||
|
@TableField("endpoint_name")
|
||||||
|
private String endpointName;
|
||||||
|
|
||||||
|
@TableField("provider_id")
|
||||||
|
private String providerId;
|
||||||
|
|
||||||
|
@TableField("endpoint_type")
|
||||||
|
private String endpointType;
|
||||||
|
|
||||||
|
@TableField("api_path")
|
||||||
|
private String apiPath;
|
||||||
|
|
||||||
|
@TableField("workflow_id")
|
||||||
|
private String workflowId;
|
||||||
|
|
||||||
|
@TableField("bot_id")
|
||||||
|
private String botId;
|
||||||
|
|
||||||
|
@TableField("model_name")
|
||||||
|
private String modelName;
|
||||||
|
|
||||||
|
@TableField("response_mode")
|
||||||
|
private String responseMode;
|
||||||
|
|
||||||
|
@TableField("request_template")
|
||||||
|
private String requestTemplate;
|
||||||
|
|
||||||
|
@TableField("default_inputs")
|
||||||
|
private String defaultInputs;
|
||||||
|
|
||||||
|
@TableField("custom_headers")
|
||||||
|
private String customHeaders;
|
||||||
|
|
||||||
|
@TableField("timeout_ms")
|
||||||
|
private Integer timeoutMs;
|
||||||
|
|
||||||
|
@TableField("support_stream")
|
||||||
|
private Integer supportStream;
|
||||||
|
|
||||||
|
@TableField("is_enabled")
|
||||||
|
private Integer isEnabled;
|
||||||
|
|
||||||
|
@TableField("description")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.emotion.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.emotion.common.BaseEntity;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@SuperBuilder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@TableName("t_ai_provider")
|
||||||
|
public class AiProvider extends BaseEntity {
|
||||||
|
|
||||||
|
@TableField("provider_code")
|
||||||
|
private String providerCode;
|
||||||
|
|
||||||
|
@TableField("provider_name")
|
||||||
|
private String providerName;
|
||||||
|
|
||||||
|
@TableField("provider_type")
|
||||||
|
private String providerType;
|
||||||
|
|
||||||
|
@TableField("base_url")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
@TableField("api_key")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@TableField("auth_type")
|
||||||
|
private String authType;
|
||||||
|
|
||||||
|
@TableField("default_headers")
|
||||||
|
private String defaultHeaders;
|
||||||
|
|
||||||
|
@TableField("timeout_ms")
|
||||||
|
private Integer timeoutMs;
|
||||||
|
|
||||||
|
@TableField("is_enabled")
|
||||||
|
private Integer isEnabled;
|
||||||
|
|
||||||
|
@TableField("description")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.emotion.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.emotion.common.BaseEntity;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@SuperBuilder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@TableName("t_ai_scene_binding")
|
||||||
|
public class AiSceneBinding extends BaseEntity {
|
||||||
|
|
||||||
|
@TableField("scene_code")
|
||||||
|
private String sceneCode;
|
||||||
|
|
||||||
|
@TableField("scene_name")
|
||||||
|
private String sceneName;
|
||||||
|
|
||||||
|
@TableField("endpoint_id")
|
||||||
|
private String endpointId;
|
||||||
|
|
||||||
|
@TableField("input_schema")
|
||||||
|
private String inputSchema;
|
||||||
|
|
||||||
|
@TableField("prompt_template")
|
||||||
|
private String promptTemplate;
|
||||||
|
|
||||||
|
@TableField("required_stream")
|
||||||
|
private Integer requiredStream;
|
||||||
|
|
||||||
|
@TableField("priority")
|
||||||
|
private Integer priority;
|
||||||
|
|
||||||
|
@TableField("is_enabled")
|
||||||
|
private Integer isEnabled;
|
||||||
|
|
||||||
|
@TableField("version")
|
||||||
|
private String version;
|
||||||
|
|
||||||
|
@TableField("description")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.emotion.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.emotion.entity.AiCallLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiCallLogMapper extends BaseMapper<AiCallLog> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.emotion.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiEndpointConfigMapper extends BaseMapper<AiEndpointConfig> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.emotion.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiProviderMapper extends BaseMapper<AiProvider> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.emotion.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.emotion.entity.AiSceneBinding;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiSceneBindingMapper extends BaseMapper<AiSceneBinding> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.emotion.entity.AiCallLog;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AiCallLogService extends IService<AiCallLog> {
|
||||||
|
|
||||||
|
List<AiCallLog> latest(Integer limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AiEndpointConfigService extends IService<AiEndpointConfig> {
|
||||||
|
|
||||||
|
List<AiEndpointConfig> listVisible();
|
||||||
|
|
||||||
|
AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint);
|
||||||
|
|
||||||
|
AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint);
|
||||||
|
|
||||||
|
AiEndpointConfig getEnabledById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AiProviderService extends IService<AiProvider> {
|
||||||
|
|
||||||
|
List<AiProvider> listVisible();
|
||||||
|
|
||||||
|
AiProvider saveProvider(AiProvider provider);
|
||||||
|
|
||||||
|
AiProvider updateProvider(AiProvider provider);
|
||||||
|
|
||||||
|
AiProvider getEnabledById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public interface AiRuntimeService {
|
||||||
|
|
||||||
|
void invokeStream(AiRuntimeRequest request, Consumer<AiStreamEvent> consumer);
|
||||||
|
|
||||||
|
AiRuntimeTestResponse test(AiRuntimeRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.emotion.entity.AiSceneBinding;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AiSceneBindingService extends IService<AiSceneBinding> {
|
||||||
|
|
||||||
|
List<AiSceneBinding> listVisible();
|
||||||
|
|
||||||
|
AiSceneBinding resolveScene(String sceneCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.emotion.dto.response.asr.AsrTranscribeResponse;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
public interface AsrService {
|
||||||
|
|
||||||
|
AsrTranscribeResponse transcribe(MultipartFile file);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.emotion.service.ai;
|
||||||
|
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public interface AiProviderAdapter {
|
||||||
|
|
||||||
|
boolean supports(String providerType);
|
||||||
|
|
||||||
|
void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.emotion.service.ai;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AiTemplateRenderer {
|
||||||
|
|
||||||
|
public Map<String, Object> mergeInputs(String defaultInputs, Map<String, Object> runtimeInputs) {
|
||||||
|
Map<String, Object> inputs = new HashMap<>();
|
||||||
|
if (StringUtils.hasText(defaultInputs)) {
|
||||||
|
try {
|
||||||
|
inputs.putAll(JSON.parseObject(defaultInputs));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
inputs.put("default_input", defaultInputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runtimeInputs != null) {
|
||||||
|
inputs.putAll(runtimeInputs);
|
||||||
|
}
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JSONObject renderObject(String template, Map<String, Object> inputs) {
|
||||||
|
if (!StringUtils.hasText(template)) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
String rendered = template;
|
||||||
|
for (Map.Entry<String, Object> entry : inputs.entrySet()) {
|
||||||
|
String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
|
||||||
|
rendered = rendered.replace("{{" + entry.getKey() + "}}", value);
|
||||||
|
}
|
||||||
|
return JSON.parseObject(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String firstText(Map<String, Object> inputs) {
|
||||||
|
for (String key : new String[]{"prompt", "message", "query", "text", "input"}) {
|
||||||
|
Object value = inputs.get(key);
|
||||||
|
if (value != null && StringUtils.hasText(String.valueOf(value))) {
|
||||||
|
return String.valueOf(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.emotion.service.ai;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CozeProviderAdapter implements AiProviderAdapter {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final AiTemplateRenderer templateRenderer;
|
||||||
|
private final ProviderHttpSupport httpSupport;
|
||||||
|
|
||||||
|
public CozeProviderAdapter(RestTemplate restTemplate, AiTemplateRenderer templateRenderer, ProviderHttpSupport httpSupport) {
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
this.templateRenderer = templateRenderer;
|
||||||
|
this.httpSupport = httpSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(String providerType) {
|
||||||
|
return "coze".equalsIgnoreCase(providerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer) {
|
||||||
|
Map<String, Object> inputs = templateRenderer.mergeInputs(endpoint.getDefaultInputs(), request.getInputs());
|
||||||
|
JSONObject body = buildRequestBody(endpoint, request, inputs);
|
||||||
|
String path = StringUtils.hasText(endpoint.getApiPath()) ? endpoint.getApiPath() : defaultPath(endpoint);
|
||||||
|
String url = httpSupport.joinUrl(provider.getBaseUrl(), path);
|
||||||
|
ProviderHttpSupport.Counter counter = new ProviderHttpSupport.Counter();
|
||||||
|
|
||||||
|
restTemplate.execute(url, HttpMethod.POST, clientRequest -> {
|
||||||
|
HttpHeaders headers = clientRequest.getHeaders();
|
||||||
|
httpSupport.applyHeaders(headers, provider, endpoint);
|
||||||
|
clientRequest.getBody().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}, response -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
httpSupport.emitSseData(line.substring(5).trim(), consumer, this::extractCozeDelta, counter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (counter.get() == 0) {
|
||||||
|
throw new IllegalStateException("AI_STREAM_NO_DELTA");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject buildRequestBody(AiEndpointConfig endpoint, AiRuntimeRequest request, Map<String, Object> inputs) {
|
||||||
|
if (StringUtils.hasText(endpoint.getRequestTemplate())) {
|
||||||
|
JSONObject rendered = templateRenderer.renderObject(endpoint.getRequestTemplate(), inputs);
|
||||||
|
rendered.putIfAbsent("stream", true);
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject body = new JSONObject();
|
||||||
|
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||||
|
body.put("workflow_id", endpoint.getWorkflowId());
|
||||||
|
body.put("parameters", inputs);
|
||||||
|
body.put("is_async", false);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.put("bot_id", endpoint.getBotId());
|
||||||
|
body.put("user_id", StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous");
|
||||||
|
body.put("stream", true);
|
||||||
|
body.put("auto_save_history", false);
|
||||||
|
JSONArray messages = new JSONArray();
|
||||||
|
JSONObject message = new JSONObject();
|
||||||
|
message.put("role", "user");
|
||||||
|
message.put("content_type", "text");
|
||||||
|
message.put("content", templateRenderer.firstText(inputs));
|
||||||
|
messages.add(message);
|
||||||
|
body.put("additional_messages", messages);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultPath(AiEndpointConfig endpoint) {
|
||||||
|
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||||
|
return "/v1/workflow/stream_run";
|
||||||
|
}
|
||||||
|
return "/v3/chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractCozeDelta(JSONObject json) {
|
||||||
|
String type = json.getString("type");
|
||||||
|
String content = json.getString("content");
|
||||||
|
if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
JSONObject message = json.getJSONObject("message");
|
||||||
|
if (message != null && StringUtils.hasText(message.getString("content"))) {
|
||||||
|
return message.getString("content");
|
||||||
|
}
|
||||||
|
JSONObject data = json.getJSONObject("data");
|
||||||
|
if (data != null) {
|
||||||
|
String output = data.getString("output");
|
||||||
|
if (StringUtils.hasText(output)) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
String answer = data.getString("answer");
|
||||||
|
if (StringUtils.hasText(answer)) {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
String dataContent = data.getString("content");
|
||||||
|
if (StringUtils.hasText(dataContent)) {
|
||||||
|
return dataContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.emotion.service.ai;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DifyProviderAdapter implements AiProviderAdapter {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final AiTemplateRenderer templateRenderer;
|
||||||
|
private final ProviderHttpSupport httpSupport;
|
||||||
|
|
||||||
|
public DifyProviderAdapter(RestTemplate restTemplate, AiTemplateRenderer templateRenderer, ProviderHttpSupport httpSupport) {
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
this.templateRenderer = templateRenderer;
|
||||||
|
this.httpSupport = httpSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(String providerType) {
|
||||||
|
return "dify".equalsIgnoreCase(providerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer) {
|
||||||
|
Map<String, Object> inputs = templateRenderer.mergeInputs(endpoint.getDefaultInputs(), request.getInputs());
|
||||||
|
JSONObject body = buildRequestBody(endpoint, request, inputs);
|
||||||
|
String path = StringUtils.hasText(endpoint.getApiPath()) ? endpoint.getApiPath() : defaultPath(endpoint);
|
||||||
|
String url = httpSupport.joinUrl(provider.getBaseUrl(), path);
|
||||||
|
ProviderHttpSupport.Counter counter = new ProviderHttpSupport.Counter();
|
||||||
|
|
||||||
|
restTemplate.execute(url, HttpMethod.POST, clientRequest -> {
|
||||||
|
HttpHeaders headers = clientRequest.getHeaders();
|
||||||
|
httpSupport.applyHeaders(headers, provider, endpoint);
|
||||||
|
clientRequest.getBody().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}, response -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
httpSupport.emitSseData(line.substring(5).trim(), consumer, this::extractDifyDelta, counter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (counter.get() == 0) {
|
||||||
|
throw new IllegalStateException("AI_STREAM_NO_DELTA");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject buildRequestBody(AiEndpointConfig endpoint, AiRuntimeRequest request, Map<String, Object> inputs) {
|
||||||
|
if (StringUtils.hasText(endpoint.getRequestTemplate())) {
|
||||||
|
JSONObject rendered = templateRenderer.renderObject(endpoint.getRequestTemplate(), inputs);
|
||||||
|
rendered.putIfAbsent("response_mode", "streaming");
|
||||||
|
rendered.putIfAbsent("user", user(request));
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject body = new JSONObject();
|
||||||
|
body.put("response_mode", "streaming");
|
||||||
|
body.put("user", user(request));
|
||||||
|
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||||
|
body.put("query", templateRenderer.firstText(inputs));
|
||||||
|
body.put("inputs", inputs);
|
||||||
|
} else {
|
||||||
|
body.put("inputs", inputs);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultPath(AiEndpointConfig endpoint) {
|
||||||
|
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||||
|
return "/chat-messages";
|
||||||
|
}
|
||||||
|
return "/workflows/run";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String user(AiRuntimeRequest request) {
|
||||||
|
return StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractDifyDelta(JSONObject json) {
|
||||||
|
String event = json.getString("event");
|
||||||
|
if ("message".equals(event) || "agent_message".equals(event)) {
|
||||||
|
return json.getString("answer");
|
||||||
|
}
|
||||||
|
if ("text_chunk".equals(event)) {
|
||||||
|
JSONObject data = json.getJSONObject("data");
|
||||||
|
return data == null ? null : data.getString("text");
|
||||||
|
}
|
||||||
|
if ("workflow_finished".equals(event)) {
|
||||||
|
JSONObject data = json.getJSONObject("data");
|
||||||
|
JSONObject outputs = data == null ? null : data.getJSONObject("outputs");
|
||||||
|
if (outputs != null) {
|
||||||
|
Object text = outputs.get("text");
|
||||||
|
if (text == null) {
|
||||||
|
text = outputs.get("answer");
|
||||||
|
}
|
||||||
|
return text == null ? null : String.valueOf(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.emotion.service.ai;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ProviderHttpSupport {
|
||||||
|
|
||||||
|
public String joinUrl(String baseUrl, String path) {
|
||||||
|
String base = StringUtils.hasText(baseUrl) ? baseUrl.trim() : "";
|
||||||
|
String suffix = StringUtils.hasText(path) ? path.trim() : "";
|
||||||
|
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||||
|
return base + "/" + suffix;
|
||||||
|
}
|
||||||
|
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||||
|
return base + suffix.substring(1);
|
||||||
|
}
|
||||||
|
return base + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void applyHeaders(HttpHeaders headers, AiProvider provider, AiEndpointConfig endpoint) {
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
if (StringUtils.hasText(provider.getApiKey())) {
|
||||||
|
headers.setBearerAuth(provider.getApiKey());
|
||||||
|
}
|
||||||
|
applyJsonHeaders(headers, provider.getDefaultHeaders());
|
||||||
|
applyJsonHeaders(headers, endpoint.getCustomHeaders());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void emitSseData(String data, Consumer<AiStreamEvent> consumer, DeltaExtractor extractor, Counter counter) {
|
||||||
|
if (!StringUtils.hasText(data) || "[DONE]".equals(data.trim())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSONObject json = JSON.parseObject(data);
|
||||||
|
String delta = extractor.extract(json);
|
||||||
|
if (StringUtils.hasText(delta)) {
|
||||||
|
counter.increment();
|
||||||
|
consumer.accept(AiStreamEvent.delta(delta, counter.get()));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
counter.increment();
|
||||||
|
consumer.accept(AiStreamEvent.delta(data, counter.get()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyJsonHeaders(HttpHeaders headers, String jsonText) {
|
||||||
|
if (!StringUtils.hasText(jsonText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSONObject json = JSON.parseObject(jsonText);
|
||||||
|
for (Map.Entry<String, Object> entry : json.entrySet()) {
|
||||||
|
if (entry.getValue() != null) {
|
||||||
|
headers.set(entry.getKey(), String.valueOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface DeltaExtractor {
|
||||||
|
String extract(JSONObject json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Counter {
|
||||||
|
private int value;
|
||||||
|
|
||||||
|
public void increment() {
|
||||||
|
value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int get() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.emotion.entity.AiCallLog;
|
||||||
|
import com.emotion.mapper.AiCallLogMapper;
|
||||||
|
import com.emotion.service.AiCallLogService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiCallLogServiceImpl extends ServiceImpl<AiCallLogMapper, AiCallLog> implements AiCallLogService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AiCallLog> latest(Integer limit) {
|
||||||
|
int size = limit == null ? 50 : Math.max(1, Math.min(limit, 200));
|
||||||
|
return list(new LambdaQueryWrapper<AiCallLog>()
|
||||||
|
.eq(AiCallLog::getIsDeleted, 0)
|
||||||
|
.orderByDesc(AiCallLog::getCreateTime)
|
||||||
|
.last("limit " + size));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.emotion.service.impl;
|
|||||||
|
|
||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
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.Message;
|
||||||
import com.emotion.entity.Conversation;
|
import com.emotion.entity.Conversation;
|
||||||
import com.emotion.entity.CozeApiCall;
|
import com.emotion.entity.CozeApiCall;
|
||||||
@@ -14,6 +16,7 @@ import com.emotion.service.CozeApiCallService;
|
|||||||
import com.emotion.service.EmotionRecordService;
|
import com.emotion.service.EmotionRecordService;
|
||||||
import com.emotion.service.EmotionAnalysisService;
|
import com.emotion.service.EmotionAnalysisService;
|
||||||
import com.emotion.service.AiConfigService;
|
import com.emotion.service.AiConfigService;
|
||||||
|
import com.emotion.service.AiRuntimeService;
|
||||||
import com.emotion.entity.AiConfig;
|
import com.emotion.entity.AiConfig;
|
||||||
import com.emotion.dto.request.*;
|
import com.emotion.dto.request.*;
|
||||||
import com.emotion.dto.response.*;
|
import com.emotion.dto.response.*;
|
||||||
@@ -79,6 +82,9 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AiConfigService aiConfigService;
|
private AiConfigService aiConfigService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AiRuntimeService aiRuntimeService;
|
||||||
|
|
||||||
private static final String DEFAULT_USER_ID = "emotion-museum-user";
|
private static final String DEFAULT_USER_ID = "emotion-museum-user";
|
||||||
|
|
||||||
// 使用场景常量
|
// 使用场景常量
|
||||||
@@ -117,9 +123,10 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
userMessage.setSender("user");
|
userMessage.setSender("user");
|
||||||
userMessage = messageService.createMessage(userMessage);
|
userMessage = messageService.createMessage(userMessage);
|
||||||
|
|
||||||
// 调用Coze API(传入messageId)
|
String aiReply = invokeRuntimeScene("chat", request.getMessage(), request.getUserId(), Map.of(
|
||||||
String aiReply = sendMessageWithMessageId(request.getConversationId(), userMessage.getId(),
|
"conversationId", request.getConversationId(),
|
||||||
request.getMessage(), request.getUserId());
|
"userMessageId", userMessage.getId()
|
||||||
|
));
|
||||||
|
|
||||||
// 保存AI回复
|
// 保存AI回复
|
||||||
Message aiMessage = new Message();
|
Message aiMessage = new Message();
|
||||||
@@ -170,8 +177,9 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
// 构建总结请求
|
// 构建总结请求
|
||||||
String summaryPrompt = "请为以下对话生成一个简洁的总结:\n\n" + conversationHistory;
|
String summaryPrompt = "请为以下对话生成一个简洁的总结:\n\n" + conversationHistory;
|
||||||
|
|
||||||
// 调用AI生成总结 - 使用专门的总结bot
|
String summary = invokeRuntimeScene("emotion_summary", summaryPrompt, request.getUserId(), Map.of(
|
||||||
String summary = sendSummaryMessage(request.getConversationId(), summaryPrompt, request.getUserId());
|
"conversationId", request.getConversationId()
|
||||||
|
));
|
||||||
|
|
||||||
log.info("对话总结生成完成: conversationId={}", request.getConversationId());
|
log.info("对话总结生成完成: conversationId={}", request.getConversationId());
|
||||||
|
|
||||||
@@ -445,16 +453,12 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String sendMessage(String conversationId, String userMessage, String userId) {
|
public String sendMessage(String conversationId, String userMessage, String userId) {
|
||||||
log.info("发送消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
|
log.info("发送聊天消息到AI运行时: conversationId={}, userId={}", conversationId, userId);
|
||||||
|
|
||||||
// 创建API调用记录(不包含messageId,用于向后兼容)
|
|
||||||
CozeApiCall apiCall = createApiCallRecord(conversationId, null, userMessage, userId, "chat");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return executeCozeApiCall(apiCall, conversationId, userMessage, userId);
|
return invokeRuntimeScene("chat", userMessage, userId, Map.of("conversationId", conversationId));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("发送消息失败", e);
|
log.error("发送聊天消息失败", e);
|
||||||
updateApiCallFailure(apiCall, e.getMessage());
|
|
||||||
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,10 +539,13 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
// Coze 中已经在工作流设置了提示词,目前不需要构建情绪分析提示词
|
// Coze 中已经在工作流设置了提示词,目前不需要构建情绪分析提示词
|
||||||
// String emotionPrompt = buildEmotionAnalysisPrompt(chatHistory);
|
// String emotionPrompt = buildEmotionAnalysisPrompt(chatHistory);
|
||||||
|
|
||||||
// 调用Coze API进行情绪分析总结
|
|
||||||
String conversationId = "emotion_summary_" + userId + "_"
|
String conversationId = "emotion_summary_" + userId + "_"
|
||||||
+ today.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
+ 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);
|
log.info("情绪分析总结生成完成: {}", emotionSummary);
|
||||||
|
|
||||||
// 解析AI返回的情绪分析结果
|
// 解析AI返回的情绪分析结果
|
||||||
@@ -572,18 +579,39 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
return CompletableFuture.completedFuture(result);
|
return CompletableFuture.completedFuture(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String invokeRuntimeScene(String sceneCode, String input, String userId, Map<String, Object> extraInputs) {
|
||||||
|
JSONObject inputs = new JSONObject();
|
||||||
|
inputs.put("input", input);
|
||||||
|
inputs.put("message", input);
|
||||||
|
inputs.put("prompt", input);
|
||||||
|
if (extraInputs != null) {
|
||||||
|
inputs.putAll(extraInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||||
|
runtimeRequest.setSceneCode(sceneCode);
|
||||||
|
runtimeRequest.setUserId(userId);
|
||||||
|
runtimeRequest.setInputs(inputs);
|
||||||
|
|
||||||
|
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||||
|
if (response == null) {
|
||||||
|
throw new IllegalStateException("AI_RUNTIME_EMPTY_RESPONSE");
|
||||||
|
}
|
||||||
|
if (!"success".equals(response.getStatus())) {
|
||||||
|
String message = response.getErrorMessage() != null ? response.getErrorMessage() : response.getErrorCode();
|
||||||
|
throw new IllegalStateException(message != null ? message : "AI_RUNTIME_FAILED");
|
||||||
|
}
|
||||||
|
return response.getOutput();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String sendSummaryMessage(String conversationId, String userMessage, String userId) {
|
public String sendSummaryMessage(String conversationId, String userMessage, String userId) {
|
||||||
log.info("发送总结消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
|
log.info("发送总结消息到AI运行时: conversationId={}, userId={}", conversationId, userId);
|
||||||
|
|
||||||
// 创建API调用记录(总结不需要messageId)
|
|
||||||
CozeApiCall apiCall = createSummaryApiCallRecord(conversationId, null, userMessage, userId, "summary");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return executeSummaryCozeApiCall(apiCall, conversationId, userMessage, userId);
|
return invokeRuntimeScene("emotion_summary", userMessage, userId, Map.of("conversationId", conversationId));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("发送总结消息失败", e);
|
log.error("发送总结消息失败", e);
|
||||||
updateApiCallFailure(apiCall, e.getMessage());
|
|
||||||
return "抱歉,AI总结服务暂时不可用,请稍后再试。";
|
return "抱歉,AI总结服务暂时不可用,请稍后再试。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -759,34 +787,17 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
*/
|
*/
|
||||||
private String sendMessageWithMessageId(String conversationId, String messageId, String userMessage,
|
private String sendMessageWithMessageId(String conversationId, String messageId, String userMessage,
|
||||||
String userId) {
|
String userId) {
|
||||||
log.info("发送消息到Coze AI: conversationId={}, messageId={}, userId={}", conversationId, messageId, userId);
|
log.info("发送聊天消息到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);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. 构建工作流请求参数
|
return invokeRuntimeScene("chat", userMessage, userId, Map.of(
|
||||||
Map<String, Object> parameters = new HashMap<>();
|
"conversationId", conversationId,
|
||||||
parameters.put("input", userMessage);
|
"userMessageId", messageId
|
||||||
parameters.put("user_id", userId);
|
));
|
||||||
|
|
||||||
// 4. 构建工作流请求体
|
|
||||||
Map<String, Object> requestBody = buildWorkflowRequest(config, parameters, userId);
|
|
||||||
|
|
||||||
// 5. 执行工作流调用(带API调用记录)
|
|
||||||
return executeWorkflowCallWithRecord(config, requestBody, COZE_CHAT_CONFIG_KEY, userId, apiCall);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("发送消息失败: conversationId={}, messageId={}, error={}",
|
log.error("发送聊天消息失败: conversationId={}, messageId={}, error={}",
|
||||||
conversationId, messageId, e.getMessage(), e);
|
conversationId, messageId, e.getMessage(), e);
|
||||||
updateApiCallFailure(apiCall, e.getMessage());
|
|
||||||
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.emotion.entity.AiEndpointConfig;
|
||||||
|
import com.emotion.mapper.AiEndpointConfigMapper;
|
||||||
|
import com.emotion.service.AiEndpointConfigService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiEndpointConfigServiceImpl extends ServiceImpl<AiEndpointConfigMapper, AiEndpointConfig> implements AiEndpointConfigService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AiEndpointConfig> listVisible() {
|
||||||
|
return list(new LambdaQueryWrapper<AiEndpointConfig>()
|
||||||
|
.eq(AiEndpointConfig::getIsDeleted, 0)
|
||||||
|
.orderByDesc(AiEndpointConfig::getCreateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint) {
|
||||||
|
applyDefaults(endpoint);
|
||||||
|
save(endpoint);
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint) {
|
||||||
|
updateById(endpoint);
|
||||||
|
return getById(endpoint.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiEndpointConfig getEnabledById(String id) {
|
||||||
|
AiEndpointConfig endpoint = getById(id);
|
||||||
|
if (endpoint == null || endpoint.getIsEnabled() == null || endpoint.getIsEnabled() != 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyDefaults(AiEndpointConfig endpoint) {
|
||||||
|
if (!StringUtils.hasText(endpoint.getResponseMode())) {
|
||||||
|
endpoint.setResponseMode("streaming");
|
||||||
|
}
|
||||||
|
if (endpoint.getSupportStream() == null) {
|
||||||
|
endpoint.setSupportStream(1);
|
||||||
|
}
|
||||||
|
if (endpoint.getIsEnabled() == null) {
|
||||||
|
endpoint.setIsEnabled(1);
|
||||||
|
}
|
||||||
|
if (endpoint.getTimeoutMs() == null) {
|
||||||
|
endpoint.setTimeoutMs(60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.emotion.entity.AiProvider;
|
||||||
|
import com.emotion.mapper.AiProviderMapper;
|
||||||
|
import com.emotion.service.AiProviderService;
|
||||||
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiProviderServiceImpl extends ServiceImpl<AiProviderMapper, AiProvider> implements AiProviderService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AiProvider> listVisible() {
|
||||||
|
return list(new LambdaQueryWrapper<AiProvider>()
|
||||||
|
.eq(AiProvider::getIsDeleted, 0)
|
||||||
|
.orderByDesc(AiProvider::getCreateTime))
|
||||||
|
.stream()
|
||||||
|
.map(this::maskSecret)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiProvider saveProvider(AiProvider provider) {
|
||||||
|
if (provider.getIsEnabled() == null) {
|
||||||
|
provider.setIsEnabled(1);
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(provider.getAuthType())) {
|
||||||
|
provider.setAuthType("bearer");
|
||||||
|
}
|
||||||
|
if (provider.getTimeoutMs() == null) {
|
||||||
|
provider.setTimeoutMs(60000);
|
||||||
|
}
|
||||||
|
save(provider);
|
||||||
|
return maskSecret(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiProvider updateProvider(AiProvider provider) {
|
||||||
|
if (!StringUtils.hasText(provider.getApiKey()) || "******".equals(provider.getApiKey())) {
|
||||||
|
provider.setApiKey(null);
|
||||||
|
}
|
||||||
|
updateById(provider);
|
||||||
|
return maskSecret(getById(provider.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiProvider getEnabledById(String id) {
|
||||||
|
AiProvider provider = getById(id);
|
||||||
|
if (provider == null || provider.getIsEnabled() == null || provider.getIsEnabled() != 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiProvider maskSecret(AiProvider provider) {
|
||||||
|
if (provider == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
AiProvider copy = new AiProvider();
|
||||||
|
BeanUtils.copyProperties(provider, copy);
|
||||||
|
if (StringUtils.hasText(copy.getApiKey())) {
|
||||||
|
copy.setApiKey("******");
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.emotion.entity.AiSceneBinding;
|
||||||
|
import com.emotion.mapper.AiSceneBindingMapper;
|
||||||
|
import com.emotion.service.AiSceneBindingService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiSceneBindingServiceImpl extends ServiceImpl<AiSceneBindingMapper, AiSceneBinding> implements AiSceneBindingService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AiSceneBinding> listVisible() {
|
||||||
|
return list(new LambdaQueryWrapper<AiSceneBinding>()
|
||||||
|
.eq(AiSceneBinding::getIsDeleted, 0)
|
||||||
|
.orderByDesc(AiSceneBinding::getPriority)
|
||||||
|
.orderByDesc(AiSceneBinding::getCreateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiSceneBinding resolveScene(String sceneCode) {
|
||||||
|
return getOne(new LambdaQueryWrapper<AiSceneBinding>()
|
||||||
|
.eq(AiSceneBinding::getSceneCode, sceneCode)
|
||||||
|
.eq(AiSceneBinding::getIsEnabled, 1)
|
||||||
|
.eq(AiSceneBinding::getIsDeleted, 0)
|
||||||
|
.orderByDesc(AiSceneBinding::getPriority)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.emotion.dto.response.asr.AsrTranscribeResponse;
|
||||||
|
import com.emotion.service.AsrService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AsrServiceImpl implements AsrService {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Value("${emotion.asr.enabled:true}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Value("${emotion.asr.engine-url:http://127.0.0.1:19120}")
|
||||||
|
private String engineUrl;
|
||||||
|
|
||||||
|
@Value("${emotion.asr.max-file-size:10485760}")
|
||||||
|
private long maxFileSize;
|
||||||
|
|
||||||
|
@Value("${emotion.asr.allowed-types:wav,mp3,m4a,mp4,aac,amr}")
|
||||||
|
private String allowedTypes;
|
||||||
|
|
||||||
|
public AsrServiceImpl(RestTemplate restTemplate) {
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AsrTranscribeResponse transcribe(MultipartFile file) {
|
||||||
|
validate(file);
|
||||||
|
try {
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return StringUtils.hasText(file.getOriginalFilename()) ? file.getOriginalFilename() : "voice.wav";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("file", resource);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
|
ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/transcribe", request, Map.class);
|
||||||
|
Map<?, ?> data = response.getBody();
|
||||||
|
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
||||||
|
if (!success) {
|
||||||
|
String message = data == null ? "ASR service returned empty response" : String.valueOf(data.get("errorMessage"));
|
||||||
|
throw new IllegalStateException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AsrTranscribeResponse.builder()
|
||||||
|
.text(stringValue(data.get("text")))
|
||||||
|
.language(stringValue(data.get("language")))
|
||||||
|
.durationMs(longValue(data.get("durationMs")))
|
||||||
|
.engine(stringValue(data.get("engine")))
|
||||||
|
.model(stringValue(data.get("model")))
|
||||||
|
.build();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("语音识别服务暂时不可用: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(MultipartFile file) {
|
||||||
|
if (!enabled) {
|
||||||
|
throw new IllegalStateException("语音识别功能未启用");
|
||||||
|
}
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("请上传语音文件");
|
||||||
|
}
|
||||||
|
if (file.getSize() > maxFileSize) {
|
||||||
|
throw new IllegalArgumentException("语音文件过大,请控制在10MB以内");
|
||||||
|
}
|
||||||
|
|
||||||
|
String filename = file.getOriginalFilename();
|
||||||
|
String extension = "";
|
||||||
|
if (StringUtils.hasText(filename) && filename.contains(".")) {
|
||||||
|
extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
Set<String> allowed = Set.of(allowedTypes.toLowerCase(Locale.ROOT).split(","));
|
||||||
|
if (StringUtils.hasText(extension) && !allowed.contains(extension)) {
|
||||||
|
throw new IllegalArgumentException("不支持的语音格式: " + extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringValue(Object value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String text = String.valueOf(value);
|
||||||
|
return "null".equals(text) ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long longValue(Object value) {
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
package com.emotion.service.impl;
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.emotion.common.PageResult;
|
import com.emotion.common.PageResult;
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
import com.emotion.dto.request.EpicScriptCreateRequest;
|
import com.emotion.dto.request.EpicScriptCreateRequest;
|
||||||
import com.emotion.dto.request.EpicScriptInspirationRequest;
|
import com.emotion.dto.request.EpicScriptInspirationRequest;
|
||||||
import com.emotion.dto.request.EpicScriptPageRequest;
|
import com.emotion.dto.request.EpicScriptPageRequest;
|
||||||
import com.emotion.dto.request.EpicScriptUpdateRequest;
|
import com.emotion.dto.request.EpicScriptUpdateRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||||
import com.emotion.dto.response.EpicScriptInspirationResponse;
|
import com.emotion.dto.response.EpicScriptInspirationResponse;
|
||||||
import com.emotion.dto.response.EpicScriptResponse;
|
import com.emotion.dto.response.EpicScriptResponse;
|
||||||
import com.emotion.dto.response.InspirationSuggestionResponse;
|
import com.emotion.dto.response.InspirationSuggestionResponse;
|
||||||
import com.emotion.entity.EpicScript;
|
import com.emotion.entity.EpicScript;
|
||||||
import com.emotion.mapper.EpicScriptMapper;
|
import com.emotion.mapper.EpicScriptMapper;
|
||||||
import com.emotion.service.AiChatService;
|
import com.emotion.service.AiRuntimeService;
|
||||||
import com.emotion.service.EpicScriptService;
|
import com.emotion.service.EpicScriptService;
|
||||||
import com.emotion.service.LifePathService;
|
import com.emotion.service.LifePathService;
|
||||||
import com.emotion.service.ScriptContextService;
|
import com.emotion.service.ScriptContextService;
|
||||||
@@ -62,14 +65,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
/**
|
/**
|
||||||
* Coze工作流配置键 - 爽文剧本生成
|
* Coze工作流配置键 - 爽文剧本生成
|
||||||
*/
|
*/
|
||||||
private static final String COZE_EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Lazy
|
@Lazy
|
||||||
private LifePathService lifePathService;
|
private LifePathService lifePathService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AiChatService aiChatService;
|
private AiRuntimeService aiRuntimeService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ScriptContextService scriptContextService;
|
private ScriptContextService scriptContextService;
|
||||||
@@ -174,7 +175,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
script.setIsSelected(request.getIsSelected() != null && request.getIsSelected() ? 1 : 0);
|
script.setIsSelected(request.getIsSelected() != null && request.getIsSelected() ? 1 : 0);
|
||||||
|
|
||||||
// 调用Coze AI生成剧本内容
|
// 调用Coze AI生成剧本内容
|
||||||
String aiGeneratedContent = generateScriptByAi(request, currentUserId);
|
String existingContent = extractExistingGeneratedContent(script.getPlotJson());
|
||||||
|
String aiGeneratedContent = StringUtils.hasText(existingContent)
|
||||||
|
? existingContent
|
||||||
|
: generateScriptByAi(request, currentUserId);
|
||||||
if (aiGeneratedContent != null) {
|
if (aiGeneratedContent != null) {
|
||||||
// 将AI生成的内容存储到plotJson中
|
// 将AI生成的内容存储到plotJson中
|
||||||
Map<String, Object> plotJson = script.getPlotJson();
|
Map<String, Object> plotJson = script.getPlotJson();
|
||||||
@@ -278,8 +282,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
String input = assembleScriptInput(request, userId);
|
String input = assembleScriptInput(request, userId);
|
||||||
log.info("开始调用AI生成剧本,用户ID: {}, 输入长度: {}", userId, input.length());
|
log.info("开始调用AI生成剧本,用户ID: {}, 输入长度: {}", userId, input.length());
|
||||||
|
|
||||||
// 调用Coze工作流
|
String result = invokeScriptRuntime(request, input, userId);
|
||||||
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
|
|
||||||
|
|
||||||
log.info("AI生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
|
log.info("AI生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
|
||||||
return result;
|
return result;
|
||||||
@@ -291,6 +294,49 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String invokeScriptRuntime(EpicScriptCreateRequest request, String input, String userId) {
|
||||||
|
JSONObject inputs = new JSONObject();
|
||||||
|
inputs.put("input", input);
|
||||||
|
inputs.put("prompt", StringUtils.hasText(request.getTheme()) ? request.getTheme() : input);
|
||||||
|
inputs.put("theme", request.getTheme());
|
||||||
|
inputs.put("style", request.getStyle());
|
||||||
|
inputs.put("length", request.getLength());
|
||||||
|
inputs.put("useSocialInsights", request.getUseSocialInsights());
|
||||||
|
return invokeRuntime("script_generate", inputs, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String invokeScriptRuntime(EpicScriptUpdateRequest request, String input, String userId) {
|
||||||
|
JSONObject inputs = new JSONObject();
|
||||||
|
inputs.put("input", input);
|
||||||
|
inputs.put("prompt", StringUtils.hasText(request.getTheme()) ? request.getTheme() : input);
|
||||||
|
inputs.put("theme", request.getTheme());
|
||||||
|
inputs.put("style", request.getStyle());
|
||||||
|
inputs.put("length", request.getLength());
|
||||||
|
inputs.put("useSocialInsights", request.getUseSocialInsights());
|
||||||
|
return invokeRuntime("script_generate", inputs, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String invokeRuntime(String sceneCode, JSONObject inputs, String userId) {
|
||||||
|
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||||
|
runtimeRequest.setSceneCode(sceneCode);
|
||||||
|
runtimeRequest.setUserId(userId);
|
||||||
|
runtimeRequest.setInputs(inputs);
|
||||||
|
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||||
|
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
|
||||||
|
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
|
||||||
|
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_EMPTY_RESPONSE");
|
||||||
|
}
|
||||||
|
return response.getOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractExistingGeneratedContent(Map<String, Object> plotJson) {
|
||||||
|
if (plotJson == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object fullContent = plotJson.get("fullContent");
|
||||||
|
return fullContent == null ? null : String.valueOf(fullContent);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装AI输入内容
|
* 组装AI输入内容
|
||||||
* 将EpicScriptCreateRequest的字段组装为格式化字符串
|
* 将EpicScriptCreateRequest的字段组装为格式化字符串
|
||||||
@@ -311,7 +357,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
sb.append("【过往经历】").append(request.getLifeEventsSummary()).append("\n");
|
sb.append("【过往经历】").append(request.getLifeEventsSummary()).append("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
String socialContext = scriptContextService == null
|
||||||
|
? null
|
||||||
|
: scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
||||||
if (StringUtils.hasText(socialContext)) {
|
if (StringUtils.hasText(socialContext)) {
|
||||||
sb.append(socialContext).append("\n");
|
sb.append(socialContext).append("\n");
|
||||||
}
|
}
|
||||||
@@ -361,6 +409,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
return sb.toString().trim();
|
return sb.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String assembleScriptInput(EpicScriptCreateRequest request) {
|
||||||
|
return assembleScriptInput(request, null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取风格描述
|
* 获取风格描述
|
||||||
*
|
*
|
||||||
@@ -478,8 +530,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
String input = assembleUpdateScriptInput(request, script, userId);
|
String input = assembleUpdateScriptInput(request, script, userId);
|
||||||
log.info("开始调用AI重新生成剧本,用户ID: {}, 剧本ID: {}", userId, script.getId());
|
log.info("开始调用AI重新生成剧本,用户ID: {}, 剧本ID: {}", userId, script.getId());
|
||||||
|
|
||||||
// 调用Coze工作流
|
String result = invokeScriptRuntime(request, input, userId);
|
||||||
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
|
|
||||||
|
|
||||||
log.info("AI重新生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
|
log.info("AI重新生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
|
||||||
return result;
|
return result;
|
||||||
@@ -514,7 +565,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
sb.append("【过往经历】").append(lifeEventsSummary).append("\n");
|
sb.append("【过往经历】").append(lifeEventsSummary).append("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
String socialContext = scriptContextService == null
|
||||||
|
? null
|
||||||
|
: scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
||||||
if (StringUtils.hasText(socialContext)) {
|
if (StringUtils.hasText(socialContext)) {
|
||||||
sb.append(socialContext).append("\n");
|
sb.append(socialContext).append("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ package com.emotion.service.impl;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.emotion.common.PageResult;
|
import com.emotion.common.PageResult;
|
||||||
import com.emotion.dto.request.LifeEventCreateRequest;
|
import com.emotion.dto.request.LifeEventCreateRequest;
|
||||||
import com.emotion.dto.request.LifeEventPageRequest;
|
import com.emotion.dto.request.LifeEventPageRequest;
|
||||||
import com.emotion.dto.request.LifeEventUpdateRequest;
|
import com.emotion.dto.request.LifeEventUpdateRequest;
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||||
import com.emotion.dto.response.LifeEventResponse;
|
import com.emotion.dto.response.LifeEventResponse;
|
||||||
import com.emotion.entity.LifeEvent;
|
import com.emotion.entity.LifeEvent;
|
||||||
import com.emotion.mapper.LifeEventMapper;
|
import com.emotion.mapper.LifeEventMapper;
|
||||||
|
import com.emotion.service.AiRuntimeService;
|
||||||
import com.emotion.service.LifeEventService;
|
import com.emotion.service.LifeEventService;
|
||||||
import com.emotion.util.UserContextHolder;
|
import com.emotion.util.UserContextHolder;
|
||||||
import com.emotion.service.AiChatService;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
@@ -44,13 +47,8 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
|||||||
private static final DateTimeFormatter DATE_ONLY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private static final DateTimeFormatter DATE_ONLY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
/**
|
|
||||||
* Coze工作流配置键 - AI疗愈
|
|
||||||
*/
|
|
||||||
private static final String COZE_HEALING_CONFIG_KEY = "coze.user.dairy.summary";
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AiChatService aiChatService;
|
private AiRuntimeService aiRuntimeService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<LifeEventResponse> getPageByCurrentUser(LifeEventPageRequest request) {
|
public PageResult<LifeEventResponse> getPageByCurrentUser(LifeEventPageRequest request) {
|
||||||
@@ -158,10 +156,11 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
|||||||
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用Coze AI进行疗愈回复
|
if (!StringUtils.hasText(event.getAiReply())) {
|
||||||
String aiGeneratedContent = generateHealingByAi(event);
|
String aiGeneratedContent = generateHealingByAi(event);
|
||||||
if (StringUtils.hasText(aiGeneratedContent)) {
|
if (StringUtils.hasText(aiGeneratedContent)) {
|
||||||
event.setAiReply(aiGeneratedContent);
|
event.setAiReply(aiGeneratedContent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.save(event);
|
this.save(event);
|
||||||
@@ -180,8 +179,23 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
|||||||
String input = assembleHealingInput(event);
|
String input = assembleHealingInput(event);
|
||||||
log.info("开始调用AI生成疗愈回复,用户ID: {}, 输入长度: {}", event.getUserId(), input.length());
|
log.info("开始调用AI生成疗愈回复,用户ID: {}, 输入长度: {}", event.getUserId(), input.length());
|
||||||
|
|
||||||
// 调用Coze工作流
|
JSONObject inputs = new JSONObject();
|
||||||
String result = aiChatService.callWorkflowByConfigKey(COZE_HEALING_CONFIG_KEY, input, event.getUserId());
|
inputs.put("mode", "life_event_analysis");
|
||||||
|
inputs.put("input", input);
|
||||||
|
inputs.put("message", input);
|
||||||
|
inputs.put("content", event.getContent());
|
||||||
|
inputs.put("title", event.getTitle());
|
||||||
|
inputs.put("emotionType", event.getEmotionType());
|
||||||
|
inputs.put("eventType", event.getEventType());
|
||||||
|
inputs.put("eventDate", event.getEventDateText());
|
||||||
|
|
||||||
|
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||||
|
runtimeRequest.setSceneCode("life_healing");
|
||||||
|
runtimeRequest.setUserId(event.getUserId());
|
||||||
|
runtimeRequest.setInputs(inputs);
|
||||||
|
|
||||||
|
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||||
|
String result = response != null ? response.getOutput() : null;
|
||||||
|
|
||||||
log.info("AI生成疗愈回复完成,用户ID: {}, 结果长度: {}", event.getUserId(), result != null ? result.length() : 0);
|
log.info("AI生成疗愈回复完成,用户ID: {}, 结果长度: {}", event.getUserId(), result != null ? result.length() : 0);
|
||||||
return result;
|
return result;
|
||||||
@@ -238,6 +252,8 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean hasClientAiReply = request.getAiReply() != null;
|
||||||
|
|
||||||
// 更新字段
|
// 更新字段
|
||||||
if (StringUtils.hasText(request.getEventType())) {
|
if (StringUtils.hasText(request.getEventType())) {
|
||||||
event.setEventType(request.getEventType());
|
event.setEventType(request.getEventType());
|
||||||
@@ -273,10 +289,11 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
|||||||
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用Coze AI进行疗愈回复
|
if (!hasClientAiReply) {
|
||||||
String aiGeneratedContent = generateHealingByAi(event);
|
String aiGeneratedContent = generateHealingByAi(event);
|
||||||
if (StringUtils.hasText(aiGeneratedContent)) {
|
if (StringUtils.hasText(aiGeneratedContent)) {
|
||||||
event.setAiReply(aiGeneratedContent);
|
event.setAiReply(aiGeneratedContent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateById(event);
|
this.updateById(event);
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.emotion.service.impl;
|
package com.emotion.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||||
import com.emotion.dto.request.WebSocketRequest;
|
import com.emotion.dto.request.WebSocketRequest;
|
||||||
|
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||||
import com.emotion.dto.websocket.ChatRequest;
|
import com.emotion.dto.websocket.ChatRequest;
|
||||||
import com.emotion.dto.websocket.ConnectRequest;
|
import com.emotion.dto.websocket.ConnectRequest;
|
||||||
import com.emotion.dto.websocket.WebSocketMessage;
|
import com.emotion.dto.websocket.WebSocketMessage;
|
||||||
import com.emotion.entity.Message;
|
import com.emotion.entity.Message;
|
||||||
import com.emotion.entity.Conversation;
|
import com.emotion.entity.Conversation;
|
||||||
import com.emotion.service.WebSocketService;
|
import com.emotion.service.WebSocketService;
|
||||||
import com.emotion.service.AiChatService;
|
import com.emotion.service.AiRuntimeService;
|
||||||
import com.emotion.service.MessageService;
|
import com.emotion.service.MessageService;
|
||||||
import com.emotion.service.ConversationService;
|
import com.emotion.service.ConversationService;
|
||||||
import com.emotion.util.SnowflakeIdGenerator;
|
import com.emotion.util.SnowflakeIdGenerator;
|
||||||
@@ -36,7 +39,7 @@ public class WebSocketServiceImpl implements WebSocketService {
|
|||||||
private SimpMessagingTemplate messagingTemplate;
|
private SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AiChatService aiChatService;
|
private AiRuntimeService aiRuntimeService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MessageService messageService;
|
private MessageService messageService;
|
||||||
@@ -238,16 +241,11 @@ public class WebSocketServiceImpl implements WebSocketService {
|
|||||||
userMessage.setCozeContentType("text");
|
userMessage.setCozeContentType("text");
|
||||||
userMessage = messageService.createMessage(userMessage);
|
userMessage = messageService.createMessage(userMessage);
|
||||||
|
|
||||||
// 调用AI服务(WebSocket专用方法,传递messageId)
|
String aiMessageId = snowflakeIdGenerator.nextIdAsString();
|
||||||
String aiReply = aiChatService.sendChatMessageForWebSocket(
|
String aiReply = streamChatReply(userId, conversationId, userMessage.getId(), aiMessageId, request.getContent());
|
||||||
conversationId,
|
if (StringUtils.hasText(aiReply)) {
|
||||||
userMessage.getId(), // 传递用户消息ID
|
saveAiMessageOnly(userId, conversationId, aiMessageId, aiReply);
|
||||||
request.getContent(),
|
}
|
||||||
userId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 根据换行符分割AI回复并按顺序发送多条消息
|
|
||||||
sendAiReplyInParts(userId, conversationId, aiReply);
|
|
||||||
|
|
||||||
// 更新会话的最后活跃时间和消息数量
|
// 更新会话的最后活跃时间和消息数量
|
||||||
updateConversationActivity(conversationId);
|
updateConversationActivity(conversationId);
|
||||||
@@ -262,6 +260,85 @@ public class WebSocketServiceImpl implements WebSocketService {
|
|||||||
/**
|
/**
|
||||||
* 发送错误消息
|
* 发送错误消息
|
||||||
*/
|
*/
|
||||||
|
private String streamChatReply(String userId, String conversationId, String userMessageId, String aiMessageId, String content) {
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||||
|
runtimeRequest.setSceneCode("chat");
|
||||||
|
runtimeRequest.setUserId(userId);
|
||||||
|
runtimeRequest.setRequestId(aiMessageId);
|
||||||
|
|
||||||
|
JSONObject inputs = new JSONObject();
|
||||||
|
inputs.put("input", content);
|
||||||
|
inputs.put("message", content);
|
||||||
|
inputs.put("prompt", content);
|
||||||
|
inputs.put("conversationId", conversationId);
|
||||||
|
inputs.put("userMessageId", userMessageId);
|
||||||
|
runtimeRequest.setInputs(inputs);
|
||||||
|
|
||||||
|
aiRuntimeService.invokeStream(runtimeRequest, event -> {
|
||||||
|
if ("delta".equals(event.getType()) && event.getContent() != null) {
|
||||||
|
output.append(event.getContent());
|
||||||
|
}
|
||||||
|
sendAiStreamEvent(userId, conversationId, aiMessageId, event);
|
||||||
|
});
|
||||||
|
return output.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendAiStreamEvent(String userId, String conversationId, String aiMessageId, AiStreamEvent event) {
|
||||||
|
String type = switch (event.getType()) {
|
||||||
|
case "start" -> "AI_STREAM_START";
|
||||||
|
case "delta" -> "AI_STREAM_DELTA";
|
||||||
|
case "done" -> "AI_STREAM_DONE";
|
||||||
|
case "error" -> "AI_STREAM_ERROR";
|
||||||
|
default -> "AI_STREAM_EVENT";
|
||||||
|
};
|
||||||
|
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("eventType", event.getType());
|
||||||
|
data.put("code", event.getCode());
|
||||||
|
data.put("message", event.getMessage());
|
||||||
|
data.put("seq", event.getSeq());
|
||||||
|
data.put("timestamp", event.getTimestamp());
|
||||||
|
data.put("metadata", event.getMetadata());
|
||||||
|
|
||||||
|
String messageContent = event.getContent();
|
||||||
|
if (!StringUtils.hasText(messageContent)) {
|
||||||
|
messageContent = StringUtils.hasText(event.getMessage()) ? event.getMessage() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocketMessage wsMessage = WebSocketMessage.builder()
|
||||||
|
.messageId(aiMessageId)
|
||||||
|
.conversationId(conversationId)
|
||||||
|
.type(type)
|
||||||
|
.content(messageContent)
|
||||||
|
.senderId("ai")
|
||||||
|
.senderType("AI")
|
||||||
|
.status("SENT")
|
||||||
|
.createTime(LocalDateTime.now())
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", wsMessage);
|
||||||
|
if (conversationId != null) {
|
||||||
|
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, wsMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveAiMessageOnly(String userId, String conversationId, String messageId, String content) {
|
||||||
|
Message aiMessage = new Message();
|
||||||
|
aiMessage.setId(messageId);
|
||||||
|
aiMessage.setConversationId(conversationId);
|
||||||
|
aiMessage.setUserId(userId);
|
||||||
|
aiMessage.setCreateBy("ai");
|
||||||
|
aiMessage.setContent(content);
|
||||||
|
aiMessage.setType("text");
|
||||||
|
aiMessage.setSender("ai");
|
||||||
|
aiMessage.setCozeRole("assistant");
|
||||||
|
aiMessage.setCozeContentType("text");
|
||||||
|
aiMessage.setTimestamp(LocalDateTime.now());
|
||||||
|
messageService.createMessage(aiMessage);
|
||||||
|
}
|
||||||
|
|
||||||
private void sendErrorMessage(String userId, String errorContent) {
|
private void sendErrorMessage(String userId, String errorContent) {
|
||||||
WebSocketMessage errorMessage = WebSocketMessage.builder()
|
WebSocketMessage errorMessage = WebSocketMessage.builder()
|
||||||
.messageId(UUID.randomUUID().toString())
|
.messageId(UUID.randomUUID().toString())
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ server:
|
|||||||
port: 19089
|
port: 19089
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-request-size: 10MB
|
||||||
|
|
||||||
# 数据库配置 - 生产MySQL
|
# 数据库配置 - 生产MySQL
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -65,6 +70,13 @@ emotion:
|
|||||||
max-text-length: 5000
|
max-text-length: 5000
|
||||||
default-voice: default_zh_female
|
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:
|
prod:
|
||||||
performance-monitoring: true
|
performance-monitoring: true
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: emotion-single
|
name: emotion-single
|
||||||
|
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-request-size: 10MB
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||||
|
|
||||||
@@ -102,6 +107,13 @@ emotion:
|
|||||||
max-text-length: 5000
|
max-text-length: 5000
|
||||||
default-voice: default_zh_female
|
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:
|
security:
|
||||||
ignore-urls:
|
ignore-urls:
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.emotion.controller;
|
package com.emotion.controller;
|
||||||
|
|
||||||
import com.emotion.service.AuthService;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.boot.test.mock.mockito.MockBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
@@ -17,7 +21,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
* @author huazhongmin
|
* @author huazhongmin
|
||||||
* @date 2025-07-26
|
* @date 2025-07-26
|
||||||
*/
|
*/
|
||||||
@WebMvcTest(AuthController.class)
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc(addFilters = false)
|
||||||
public class AuthControllerTest {
|
public class AuthControllerTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -26,13 +31,28 @@ public class AuthControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private AuthService authService;
|
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
|
@Test
|
||||||
public void testCheckAccountExists() throws Exception {
|
public void testCheckAccountExists() throws Exception {
|
||||||
// 模拟账户存在的情况
|
// 模拟账户存在的情况
|
||||||
when(authService.existsByAccount("existingUser")).thenReturn(true);
|
when(authService.existsByAccount("existingUser")).thenReturn(true);
|
||||||
|
|
||||||
mockMvc.perform(get("/auth/check-account")
|
mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api")
|
||||||
.param("account", "existingUser"))
|
.param("account", "existingUser")
|
||||||
|
.header("Authorization", "Bearer test-token"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").value(true));
|
.andExpect(jsonPath("$.data").value(true));
|
||||||
@@ -43,8 +63,9 @@ public class AuthControllerTest {
|
|||||||
// 模拟账户不存在的情况
|
// 模拟账户不存在的情况
|
||||||
when(authService.existsByAccount("nonExistingUser")).thenReturn(false);
|
when(authService.existsByAccount("nonExistingUser")).thenReturn(false);
|
||||||
|
|
||||||
mockMvc.perform(get("/auth/check-account")
|
mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api")
|
||||||
.param("account", "nonExistingUser"))
|
.param("account", "nonExistingUser")
|
||||||
|
.header("Authorization", "Bearer test-token"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").value(false));
|
.andExpect(jsonPath("$.data").value(false));
|
||||||
@@ -55,8 +76,9 @@ public class AuthControllerTest {
|
|||||||
// 模拟邮箱存在的情况
|
// 模拟邮箱存在的情况
|
||||||
when(authService.existsByEmail("existing@example.com")).thenReturn(true);
|
when(authService.existsByEmail("existing@example.com")).thenReturn(true);
|
||||||
|
|
||||||
mockMvc.perform(get("/auth/check-email")
|
mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api")
|
||||||
.param("email", "existing@example.com"))
|
.param("email", "existing@example.com")
|
||||||
|
.header("Authorization", "Bearer test-token"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").value(true));
|
.andExpect(jsonPath("$.data").value(true));
|
||||||
@@ -67,8 +89,9 @@ public class AuthControllerTest {
|
|||||||
// 模拟邮箱不存在的情况
|
// 模拟邮箱不存在的情况
|
||||||
when(authService.existsByEmail("nonexisting@example.com")).thenReturn(false);
|
when(authService.existsByEmail("nonexisting@example.com")).thenReturn(false);
|
||||||
|
|
||||||
mockMvc.perform(get("/auth/check-email")
|
mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api")
|
||||||
.param("email", "nonexisting@example.com"))
|
.param("email", "nonexisting@example.com")
|
||||||
|
.header("Authorization", "Bearer test-token"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").value(false));
|
.andExpect(jsonPath("$.data").value(false));
|
||||||
@@ -79,8 +102,9 @@ public class AuthControllerTest {
|
|||||||
// 模拟手机号存在的情况
|
// 模拟手机号存在的情况
|
||||||
when(authService.existsByPhone("13800138000")).thenReturn(true);
|
when(authService.existsByPhone("13800138000")).thenReturn(true);
|
||||||
|
|
||||||
mockMvc.perform(get("/auth/check-phone")
|
mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api")
|
||||||
.param("phone", "13800138000"))
|
.param("phone", "13800138000")
|
||||||
|
.header("Authorization", "Bearer test-token"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").value(true));
|
.andExpect(jsonPath("$.data").value(true));
|
||||||
@@ -91,8 +115,9 @@ public class AuthControllerTest {
|
|||||||
// 模拟手机号不存在的情况
|
// 模拟手机号不存在的情况
|
||||||
when(authService.existsByPhone("13900139000")).thenReturn(false);
|
when(authService.existsByPhone("13900139000")).thenReturn(false);
|
||||||
|
|
||||||
mockMvc.perform(get("/auth/check-phone")
|
mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api")
|
||||||
.param("phone", "13900139000"))
|
.param("phone", "13900139000")
|
||||||
|
.header("Authorization", "Bearer test-token"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").value(false));
|
.andExpect(jsonPath("$.data").value(false));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.RepeatedTest;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.util.AopTestUtils;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -36,6 +37,8 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AiChatService aiChatService;
|
private AiChatService aiChatService;
|
||||||
|
|
||||||
|
private AiChatServiceImpl aiChatServiceImpl;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AiConfigService aiConfigService;
|
private AiConfigService aiConfigService;
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void setUp() {
|
public void setUp() {
|
||||||
random = new Random();
|
random = new Random();
|
||||||
|
aiChatServiceImpl = AopTestUtils.getTargetObject(aiChatService);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Property 1: Request Format Correctness ====================
|
// ==================== Property 1: Request Format Correctness ====================
|
||||||
@@ -82,7 +86,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> requestBody = (Map<String, Object>) buildWorkflowRequestMethod.invoke(
|
Map<String, Object> requestBody = (Map<String, Object>) buildWorkflowRequestMethod.invoke(
|
||||||
aiChatService, config, parameters, userId);
|
aiChatServiceImpl, config, parameters, userId);
|
||||||
|
|
||||||
// 验证必需字段
|
// 验证必需字段
|
||||||
// 2.1: workflow_id - 应该与数据库配置一致
|
// 2.1: workflow_id - 应该与数据库配置一致
|
||||||
@@ -155,7 +159,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
parseMethod.setAccessible(true);
|
parseMethod.setAccessible(true);
|
||||||
|
|
||||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||||
|
|
||||||
// 验证正确提取output内容
|
// 验证正确提取output内容
|
||||||
assertEquals("这是AI生成的内容", result,
|
assertEquals("这是AI生成的内容", result,
|
||||||
@@ -184,7 +188,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
parseMethod.setAccessible(true);
|
parseMethod.setAccessible(true);
|
||||||
|
|
||||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||||
|
|
||||||
// 验证正确提取随机output内容
|
// 验证正确提取随机output内容
|
||||||
assertEquals(randomOutput, result,
|
assertEquals(randomOutput, result,
|
||||||
@@ -218,7 +222,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
parseMethod.setAccessible(true);
|
parseMethod.setAccessible(true);
|
||||||
|
|
||||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||||
|
|
||||||
// 验证只提取End节点的内容
|
// 验证只提取End节点的内容
|
||||||
assertEquals("最终输出内容", result,
|
assertEquals("最终输出内容", result,
|
||||||
@@ -244,7 +248,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
parseMethod.setAccessible(true);
|
parseMethod.setAccessible(true);
|
||||||
|
|
||||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||||
|
|
||||||
// 当content不是JSON或没有output字段时,应返回原始content
|
// 当content不是JSON或没有output字段时,应返回原始content
|
||||||
assertEquals("直接内容,没有output字段", result,
|
assertEquals("直接内容,没有output字段", result,
|
||||||
@@ -276,7 +280,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> mergedParams = (Map<String, Object>) mergeMethod.invoke(
|
Map<String, Object> mergedParams = (Map<String, Object>) mergeMethod.invoke(
|
||||||
aiChatService, config, runtimeParams);
|
aiChatServiceImpl, config, runtimeParams);
|
||||||
|
|
||||||
// 验证运行时参数被正确设置
|
// 验证运行时参数被正确设置
|
||||||
assertEquals(runtimeInput, mergedParams.get("input"),
|
assertEquals(runtimeInput, mergedParams.get("input"),
|
||||||
@@ -295,7 +299,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
extractMethod.setAccessible(true);
|
extractMethod.setAccessible(true);
|
||||||
|
|
||||||
String content = "{\"output\":\"提取的内容\"}";
|
String content = "{\"output\":\"提取的内容\"}";
|
||||||
String result = (String) extractMethod.invoke(aiChatService, content);
|
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||||
|
|
||||||
assertEquals("提取的内容", result, "应正确提取output字段");
|
assertEquals("提取的内容", result, "应正确提取output字段");
|
||||||
}
|
}
|
||||||
@@ -308,7 +312,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
extractMethod.setAccessible(true);
|
extractMethod.setAccessible(true);
|
||||||
|
|
||||||
String content = "这不是JSON内容";
|
String content = "这不是JSON内容";
|
||||||
String result = (String) extractMethod.invoke(aiChatService, content);
|
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||||
|
|
||||||
assertEquals("这不是JSON内容", result, "非JSON内容应原样返回");
|
assertEquals("这不是JSON内容", result, "非JSON内容应原样返回");
|
||||||
}
|
}
|
||||||
@@ -324,7 +328,7 @@ public class CozeWorkflowIntegrationTest {
|
|||||||
String randomOutput = "随机输出_" + UUID.randomUUID().toString();
|
String randomOutput = "随机输出_" + UUID.randomUUID().toString();
|
||||||
String content = "{\"output\":\"" + randomOutput + "\"}";
|
String content = "{\"output\":\"" + randomOutput + "\"}";
|
||||||
|
|
||||||
String result = (String) extractMethod.invoke(aiChatService, content);
|
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||||
|
|
||||||
assertEquals(randomOutput, result, "应正确提取随机生成的output内容");
|
assertEquals(randomOutput, result, "应正确提取随机生成的output内容");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<object>` | 否 | 文件列表。适用于模型支持 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<RetrieverResource>` | 引用和归属分段列表。 |
|
||||||
|
| `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` | 分段内容。 |
|
||||||
|
|
||||||
@@ -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<AsrTranscribeResponse>` 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.
|
||||||
@@ -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 行的 `<template #footer>`,修改为:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="testDialog = false">关闭</el-button>
|
||||||
|
<el-button :loading="testing" @click="submitNonStreamTest">非流式测试</el-button>
|
||||||
|
<el-button type="primary" :loading="testing" @click="submitRuntimeTest">流式测试</el-button>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 添加 `nonStreamResult` 响应式变量
|
||||||
|
|
||||||
|
在 `testResult` 声明后面(第 296 行)添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const nonStreamResult = ref<AiRuntimeTestResponse | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 实现 `submitNonStreamTest` 函数
|
||||||
|
|
||||||
|
在 `submitRuntimeTest` 函数(第 451 行)之前添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function submitNonStreamTest() {
|
||||||
|
let inputs: Record<string, any>
|
||||||
|
try {
|
||||||
|
inputs = JSON.parse(testInputsJson.value || '{}')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('入参 JSON 格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const res = await testAiRuntime({ sceneCode: testForm.sceneCode, inputs })
|
||||||
|
nonStreamResult.value = res.data as AiRuntimeTestResponse
|
||||||
|
if (nonStreamResult.value.status === 'success') {
|
||||||
|
ElMessage.success('非流式测试成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(`测试失败: ${nonStreamResult.value.errorMessage || nonStreamResult.value.errorCode}`)
|
||||||
|
}
|
||||||
|
await loadAll()
|
||||||
|
} catch (error: any) {
|
||||||
|
nonStreamResult.value = {
|
||||||
|
sceneCode: testForm.sceneCode,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error?.message || '非流式测试失败'
|
||||||
|
} as AiRuntimeTestResponse
|
||||||
|
ElMessage.error(error?.message || '非流式测试失败')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 在测试对话框中展示非流式测试结果
|
||||||
|
|
||||||
|
在 `submitRuntimeTest` 相关的 `<el-alert>` 和 `<pre>` 展示块(第 250-256 行)之前,添加非流式测试结果的展示:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-alert
|
||||||
|
v-if="nonStreamResult"
|
||||||
|
:type="nonStreamResult.status === 'success' ? 'success' : 'error'"
|
||||||
|
:title="nonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 12px;"
|
||||||
|
/>
|
||||||
|
<pre v-if="nonStreamResult" class="test-output">{{ nonStreamResult.output || nonStreamResult.errorMessage || '暂无输出' }}</pre>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="testResult"
|
||||||
|
:type="testResult.status === 'success' ? 'success' : 'error'"
|
||||||
|
:title="testResult.status === 'success' ? '流式测试成功' : '流式测试失败'"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<pre v-if="testResult" class="test-output">{{ testResult.output || testResult.errorMessage || '暂无输出' }}</pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 在打开测试对话框时清空非流式结果
|
||||||
|
|
||||||
|
在 `openRuntimeTest` 函数(第 400-404 行)中添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function openRuntimeTest() {
|
||||||
|
testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || ''
|
||||||
|
testResult.value = null
|
||||||
|
nonStreamResult.value = null // ← 新增
|
||||||
|
testDialog.value = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 在 TypeScript 类型定义中确保 `AiRuntimeTestResponse` 包含所有必要字段
|
||||||
|
|
||||||
|
检查 `web-admin/src/types/aiconfig.ts` 中的 `AiRuntimeTestResponse` 定义,确保包含:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AiRuntimeTestResponse {
|
||||||
|
sceneCode: string
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
output?: string
|
||||||
|
streamChunks?: number
|
||||||
|
durationMs?: number
|
||||||
|
errorCode?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: 浏览器验证
|
||||||
|
|
||||||
|
- [ ] 启动 web-admin 开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-admin && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 在浏览器中访问管理后台 `http://localhost:5174/emotion-museum-admin/`
|
||||||
|
|
||||||
|
- [ ] 导航到「AI 配置管理」页面,确认旧页面已删除、新页面正常显示
|
||||||
|
|
||||||
|
- [ ] 在「场景绑定」Tab 中点击「流式测试」按钮,确认测试对话框正常打开
|
||||||
|
|
||||||
|
- [ ] 确认测试对话框中有两个按钮:「非流式测试」和「流式测试」
|
||||||
|
|
||||||
|
- [ ] 选择一个场景(如 `script_generate`),输入入参 `{ "prompt": "请用一句中文回复测试成功。" }`,点击「非流式测试」,确认返回成功结果
|
||||||
|
|
||||||
|
- [ ] 点击「流式测试」,确认流式输出正常
|
||||||
|
|
||||||
|
- [ ] 确认浏览器 Console 中没有任何错误
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# AI Routing Admin Runtime Fix 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:** Make the AI configuration center match the existing admin theme, use Chinese copy everywhere, show existing provider/workflow bindings from the database, and expose a single user-facing streaming runtime entry point driven by scene code plus JSON inputs.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing provider/endpoint/scene/log model. The admin page uses the current Element Plus dark glass theme and existing list-page conventions. Runtime calls accept `sceneCode` and `inputs` JSON, enrich inputs with the logged-in user context on the backend, resolve the scene binding from the database, then stream provider output back to the user.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot, MyBatis-Plus, FastJSON, Vue 3, Element Plus, uni-app chunked streaming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Admin Page Theme And Chinese Copy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue`
|
||||||
|
|
||||||
|
- [x] Replace English page title, tabs, table columns, dialogs, buttons, alerts, empty states, and confirm text with Chinese.
|
||||||
|
- [x] Replace the standalone white panel styles with the project page-header, glass card, table card, and dark Element Plus table styles.
|
||||||
|
- [x] Add summary cards for providers, endpoints, scenes, and logs so the page matches existing management pages.
|
||||||
|
|
||||||
|
### Task 2: User Runtime Request Shape
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java`
|
||||||
|
- Modify: `backend-single/src/main/java/com/emotion/controller/AiRoutingController.java`
|
||||||
|
- Modify: `backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java`
|
||||||
|
|
||||||
|
- [x] Accept user calls as `sceneCode` plus `inputs` JSON.
|
||||||
|
- [x] Keep user identity server-side by reading `UserContextHolder`.
|
||||||
|
- [x] Enrich runtime inputs with safe user context keys before provider invocation.
|
||||||
|
- [x] Keep SSE stream events as the only formal output path for user-facing AI calls.
|
||||||
|
|
||||||
|
### Task 3: Seed Existing Configurations Into Routing Tables
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `sql/2026-05-22-ai-scene-routing.sql`
|
||||||
|
|
||||||
|
- [x] Insert a Coze provider from existing `t_ai_config` rows when missing.
|
||||||
|
- [x] Insert endpoint rows from current enabled Coze workflow configurations.
|
||||||
|
- [x] Bind existing scenes to known workflow configs where a safe mapping exists.
|
||||||
|
- [x] Keep unknown scenes visible but disabled until explicitly bound in the admin page.
|
||||||
|
|
||||||
|
### Task 4: Verify End To End
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
- `mvn test`
|
||||||
|
- `mvn -DskipTests clean package`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run build:mp-weixin`
|
||||||
|
- Remote SQL migration execution.
|
||||||
|
- Remote health checks for backend and ASR.
|
||||||
|
|
||||||
|
- [x] Backend tests pass.
|
||||||
|
- [x] Backend package succeeds.
|
||||||
|
- [x] Admin build succeeds.
|
||||||
|
- [x] Mini program build succeeds.
|
||||||
|
- [x] Remote routing tables contain provider, endpoints, and scene bindings.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# AI Runtime Client Sync 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:** Synchronize AI calls in web, web-admin, life-script, and mini-program so user-facing AI output uses the backend scene routing runtime and streams text progressively.
|
||||||
|
|
||||||
|
**Architecture:** The backend remains the single trust boundary for provider credentials, scene bindings, user context enrichment, and workflow selection. Browser and mini-program clients call `/ai/runtime/stream` with `sceneCode` and `inputs`, while existing WebSocket chat is bridged through `AiRuntimeService` so chat also follows the configured `chat` scene. Existing business save APIs remain responsible for persistence after a stream completes.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot, STOMP WebSocket, FastJSON, Vue 3, React/Vite, uni-app chunked request, Element Plus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend Chat Bridge To Runtime
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend-single/src/main/java/com/emotion/service/impl/WebSocketServiceImpl.java`
|
||||||
|
- Modify: `backend-single/src/main/java/com/emotion/dto/websocket/WebSocketMessage.java`
|
||||||
|
|
||||||
|
- [x] Route chat generation through `AiRuntimeService` with `sceneCode=chat`.
|
||||||
|
- [x] Push `start`, `delta`, `done`, and `error` events over the existing user WebSocket topic.
|
||||||
|
- [x] Save the final assistant message after `done` using the accumulated stream output.
|
||||||
|
- [x] Preserve the existing user-message persistence and conversation ID behavior.
|
||||||
|
|
||||||
|
### Task 2: Shared Runtime Clients
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/services/aiRuntime.ts`
|
||||||
|
- Create: `life-script/src/services/aiRuntime.js`
|
||||||
|
- Modify: `mini-program/src/services/aiRuntime.js`
|
||||||
|
- Modify: `web-admin/src/api/aiconfig.ts`
|
||||||
|
- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue`
|
||||||
|
|
||||||
|
- [x] Add browser SSE client helpers that parse unified AI stream events.
|
||||||
|
- [x] Keep mini-program chunk parsing but localize error messages and flush trailing frames.
|
||||||
|
- [x] Add a web-admin stream test helper and make the test dialog render output progressively.
|
||||||
|
|
||||||
|
### Task 3: Client Scene Migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `life-script/src/services/ai.js`
|
||||||
|
- Modify: `life-script/src/views/ScriptView.jsx`
|
||||||
|
- Modify: `life-script/src/views/TimelineView.jsx`
|
||||||
|
- Modify: `life-script/src/views/PathView.jsx`
|
||||||
|
- Modify: `mini-program/src/pages/main/ScriptView.vue`
|
||||||
|
- Inspect: `web/src/stores/chat.ts`
|
||||||
|
|
||||||
|
- [x] Remove direct OpenRouter calls and hard-coded client API keys.
|
||||||
|
- [x] Route `script_generate`, `life_healing`, and path generation through `streamAiScene`.
|
||||||
|
- [x] Ensure pages append `delta` content instead of waiting for complete text.
|
||||||
|
- [x] Keep final persistence through existing business save APIs after stream completion.
|
||||||
|
|
||||||
|
### Task 4: Verification
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
- `mvn test`
|
||||||
|
- `mvn -DskipTests clean package`
|
||||||
|
- `npm run build` in `web`
|
||||||
|
- `npm run build` in `web-admin`
|
||||||
|
- `npm run build` in `life-script`
|
||||||
|
- `npm run build:mp-weixin` in `mini-program`
|
||||||
|
|
||||||
|
- [x] Backend tests pass.
|
||||||
|
- [x] Backend package succeeds.
|
||||||
|
- [x] All four frontend builds succeed.
|
||||||
|
- [x] Source scan shows no client-side external AI provider key or direct Dify/Coze/OpenRouter call.
|
||||||
|
- [x] Database scene bindings are enabled with streaming endpoints for `chat`, `script_generate`, `short_story_generate`, `diary_summary`, `emotion_summary`, `emotion_analysis`, and `life_healing`.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
author: 华钟民
|
||||||
|
created_at: 2026-05-23
|
||||||
|
purpose: 修复 AI 配置管理接口测试功能,确保 Dify/Coze 等多提供商在 web-admin 后台能正常测试,全局确认前后端调用链路一致
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI 配置管理接口测试修复设计
|
||||||
|
|
||||||
|
## 1. 问题背景
|
||||||
|
|
||||||
|
在 web-admin 后台的"AI 配置管理"页面,流式测试 Dify 接口时报错:
|
||||||
|
`"message":"user_id is required in input form"`。
|
||||||
|
|
||||||
|
根因是存在两套 AI 配置系统:
|
||||||
|
- **旧系统**:`t_ai_config` 表 + `AiConfigList.vue`(已被路由废弃但文件残留)
|
||||||
|
- **新系统**:`t_ai_provider` / `t_ai_endpoint_config` / `t_ai_scene_binding` 三表 + `AiRoutingList.vue`(路由当前指向的页面)
|
||||||
|
|
||||||
|
## 2. 当前状态确认
|
||||||
|
|
||||||
|
### 前端调用链路(全部走新系统)
|
||||||
|
|
||||||
|
| 项目 | 调用方式 | 目标接口 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| web | `streamAiScene()` → `fetch('/ai/runtime/stream')` | AiRuntimeService.invokeStream() | ✅ |
|
||||||
|
| web-admin | `streamAiRuntime()` → `fetch('/ai/runtime/stream')` | AiRoutingController.runtimeStream() | ✅ |
|
||||||
|
| life-script | `streamAiScene()` → `fetch('/ai/runtime/stream')` | AiRuntimeService.invokeStream() | ✅ |
|
||||||
|
| mini-program | `streamAiScene()` → `uni.request('/ai/runtime/stream')` | AiRuntimeService.invokeStream() | ✅ |
|
||||||
|
|
||||||
|
### 后端服务调用链路(全部走新系统)
|
||||||
|
|
||||||
|
| 服务 | 调用方式 | 目标方法 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| AiChatServiceImpl | `aiRuntimeService.test()` | AiRuntimeServiceImpl.test() | ✅ |
|
||||||
|
| WebSocketServiceImpl | `aiRuntimeService.invokeStream()` | AiRuntimeServiceImpl.invokeStream() | ✅ |
|
||||||
|
| EpicScriptServiceImpl | `aiRuntimeService.test()` | AiRuntimeServiceImpl.test() | ✅ |
|
||||||
|
| LifeEventServiceImpl | `aiRuntimeService.test()` | AiRuntimeServiceImpl.test() | ✅ |
|
||||||
|
|
||||||
|
### 数据库配置现状
|
||||||
|
|
||||||
|
`t_ai_provider` 已有 `dify_default`(`http://49.232.138.53/v1`,api_key=`app-MqQOx09gCu9zzlKMpeLqHQHv`),`t_ai_endpoint_config` 已迁移 Dify 对话接口。但场景绑定需注意:
|
||||||
|
|
||||||
|
- `chat` → Coze endpoint(旧系统遗留绑定)
|
||||||
|
- `script_generate` → **Dify endpoint** ✅
|
||||||
|
- `short_story_generate` → **Dify endpoint** ✅
|
||||||
|
- `diary_summary` → Coze endpoint
|
||||||
|
- `emotion_summary` → Coze endpoint
|
||||||
|
- `emotion_analysis` → Coze endpoint
|
||||||
|
- `life_healing` → Coze endpoint
|
||||||
|
|
||||||
|
**结论**:在 web-admin 测试 `script_generate` 和 `short_story_generate` 场景时,会走 Dify;其他场景走 Coze。这是预期行为。
|
||||||
|
|
||||||
|
## 3. 设计方案
|
||||||
|
|
||||||
|
### 3.1 删除旧系统残留
|
||||||
|
|
||||||
|
删除 `web-admin/src/views/aiconfig/AiConfigList.vue`。该文件路由已不再指向它,保留只会引起混淆。对应的后端 `/aiConfig/*` 接口保持不变(以防有遗留数据查询需求)。
|
||||||
|
|
||||||
|
### 3.2 新系统测试对话框增强
|
||||||
|
|
||||||
|
在 `AiRoutingList.vue` 的流式测试对话框(当前标题"流式调用测试")中增加「非流式测试」按钮,并将对话框标题改为「接口测试」:
|
||||||
|
|
||||||
|
- 流式测试走 `/ai/runtime/stream` → `streamAiRuntime()`(已有)
|
||||||
|
- 非流式测试走 `/ai/runtime/test` → `testAiRuntime()`(已有接口,但当前未 import)
|
||||||
|
- 两个按钮共享 `testing` 加载状态,避免并发冲突
|
||||||
|
- 非流式和流式测试结果各自独立展示,互不干扰
|
||||||
|
- 打开对话框时同时清空两个结果
|
||||||
|
|
||||||
|
### 3.3 路由清理
|
||||||
|
|
||||||
|
路由配置 `/aiconfig/list` 已正确指向 `AiRoutingList.vue`,无需改动。
|
||||||
|
|
||||||
|
### 3.4 数据库配置检查
|
||||||
|
|
||||||
|
实施前需确认生产数据库中 Dify provider 和 Dify endpoints 处于 `is_enabled = 1` 状态。SQL 迁移脚本 `2026-05-22-ai-scene-routing.sql` 中已设置了 `is_enabled = 1`,但可能在实际部署时未执行。
|
||||||
|
|
||||||
|
## 4. 风险
|
||||||
|
|
||||||
|
- 删除 `AiConfigList.vue` 后如果用户需要查询旧 `t_ai_config` 数据,仍有后端 `/aiConfig/*` 接口可用
|
||||||
|
- 非流式测试新增不影响已有功能
|
||||||
|
- Dify provider 和 endpoint 需确保 `is_enabled = 1`,否则测试会返回 `AI_ENDPOINT_DISABLED` 或 `AI_PROVIDER_DISABLED`
|
||||||
+205
-124
@@ -1,239 +1,320 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
life-script 部署脚本
|
Life-Script deployment script.
|
||||||
功能:项目构建、文件传输、原子切换、历史版本管理、回滚支持
|
|
||||||
使用系统自带的ssh/scp命令,无需额外依赖
|
Builds the local project, uploads dist files to the server, switches the live
|
||||||
|
symlink atomically, keeps recent releases, and supports rollback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import shlex
|
||||||
import subprocess
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# 服务器配置
|
if hasattr(sys.stdout, "buffer"):
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||||
|
|
||||||
SERVER_IP = "101.200.208.45"
|
SERVER_IP = "101.200.208.45"
|
||||||
USERNAME = "root"
|
USERNAME = "root"
|
||||||
|
|
||||||
# 部署配置
|
|
||||||
APP_NAME = "life-script"
|
APP_NAME = "life-script"
|
||||||
DEPLOY_BASE = "/data/www/course-web-deploy"
|
DEPLOY_BASE = "/data/www/course-web-deploy"
|
||||||
RELEASES_DIR = f"{DEPLOY_BASE}/releases"
|
RELEASES_DIR = f"{DEPLOY_BASE}/releases"
|
||||||
LINK_PATH = "/data/www/course-of-life"
|
LINK_PATH = "/data/www/course-of-life"
|
||||||
MAX_RELEASES = 5
|
MAX_RELEASES = 5
|
||||||
|
|
||||||
# 本地路径
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.absolute()
|
SCRIPT_DIR = Path(__file__).parent.absolute()
|
||||||
DIST_DIR = SCRIPT_DIR / "dist"
|
DIST_DIR = SCRIPT_DIR / "dist"
|
||||||
|
|
||||||
|
SSH_BASE = [
|
||||||
|
"ssh",
|
||||||
|
"-T",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
f"{USERNAME}@{SERVER_IP}",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCP_BASE = [
|
||||||
|
"scp",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
"""终端颜色"""
|
GREEN = "\033[32m"
|
||||||
GREEN = '\033[32m'
|
RED = "\033[31m"
|
||||||
RED = '\033[31m'
|
YELLOW = "\033[33m"
|
||||||
YELLOW = '\033[33m'
|
RESET = "\033[0m"
|
||||||
RESET = '\033[0m'
|
|
||||||
|
|
||||||
|
|
||||||
def log_info(msg):
|
def log_info(msg):
|
||||||
"""打印信息日志"""
|
print(f"{Colors.GREEN}[INFO]{Colors.RESET} {msg}", flush=True)
|
||||||
print(f"{Colors.GREEN}[INFO]{Colors.RESET} {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def log_error(msg):
|
def log_error(msg):
|
||||||
"""打印错误日志"""
|
print(f"{Colors.RED}[ERROR]{Colors.RESET} {msg}", flush=True)
|
||||||
print(f"{Colors.RED}[ERROR]{Colors.RESET} {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def log_warn(msg):
|
def log_warn(msg):
|
||||||
"""打印警告日志"""
|
print(f"{Colors.YELLOW}[WARN]{Colors.RESET} {msg}", flush=True)
|
||||||
print(f"{Colors.YELLOW}[WARN]{Colors.RESET} {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd, cwd=None, shell=True, capture=True):
|
def _reader_thread(stream, sink, prefix=""):
|
||||||
"""执行本地命令"""
|
for line in iter(stream.readline, ""):
|
||||||
|
sink.append(line)
|
||||||
|
print(f"{prefix}{line}", end="", flush=True)
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_stream(args, cwd=None, timeout=None):
|
||||||
|
"""Run a command and stream stdout/stderr in real time."""
|
||||||
|
stdout_lines = []
|
||||||
|
stderr_lines = []
|
||||||
try:
|
try:
|
||||||
if capture:
|
process = subprocess.Popen(
|
||||||
result = subprocess.run(
|
args,
|
||||||
cmd,
|
cwd=cwd,
|
||||||
cwd=cwd,
|
shell=False,
|
||||||
shell=shell,
|
stdout=subprocess.PIPE,
|
||||||
capture_output=True,
|
stderr=subprocess.PIPE,
|
||||||
text=True
|
text=True,
|
||||||
)
|
encoding="utf-8",
|
||||||
return result.returncode == 0, result.stdout, result.stderr
|
errors="replace",
|
||||||
else:
|
)
|
||||||
result = subprocess.run(cmd, cwd=cwd, shell=shell)
|
stdout_thread = threading.Thread(target=_reader_thread, args=(process.stdout, stdout_lines))
|
||||||
return result.returncode == 0, "", ""
|
stderr_thread = threading.Thread(target=_reader_thread, args=(process.stderr, stderr_lines))
|
||||||
except Exception as e:
|
stdout_thread.start()
|
||||||
return False, "", str(e)
|
stderr_thread.start()
|
||||||
|
try:
|
||||||
|
returncode = process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
returncode = process.wait()
|
||||||
|
log_error(f"命令执行超时 ({timeout}s): {' '.join(map(str, args))}")
|
||||||
|
return False, "".join(stdout_lines), "".join(stderr_lines) or "命令执行超时"
|
||||||
|
finally:
|
||||||
|
stdout_thread.join(timeout=2)
|
||||||
|
stderr_thread.join(timeout=2)
|
||||||
|
return returncode == 0, "".join(stdout_lines), "".join(stderr_lines)
|
||||||
|
except Exception as exc:
|
||||||
|
return False, "".join(stdout_lines), str(exc)
|
||||||
|
|
||||||
|
|
||||||
def exec_ssh_cmd(cmd, timeout=30):
|
def run_capture(args, cwd=None, timeout=None):
|
||||||
"""通过SSH执行远程命令"""
|
"""Run a command and capture output for decision making."""
|
||||||
ssh_cmd = f'ssh -o ConnectTimeout=10 -o BatchMode=yes {USERNAME}@{SERVER_IP} "{cmd}"'
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
ssh_cmd,
|
args,
|
||||||
shell=True,
|
cwd=cwd,
|
||||||
|
shell=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
|
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
log_error(f"SSH命令超时: {cmd}")
|
return False, "", f"命令执行超时 ({timeout}s): {' '.join(map(str, args))}"
|
||||||
return False, "", "命令执行超时"
|
except Exception as exc:
|
||||||
except Exception as e:
|
return False, "", str(exc)
|
||||||
return False, "", str(e)
|
|
||||||
|
|
||||||
|
def run_command(cmd, cwd=None, capture=True, timeout=None):
|
||||||
|
"""Run a local shell command."""
|
||||||
|
args = cmd if isinstance(cmd, list) else ["cmd", "/c", cmd] if os.name == "nt" else ["bash", "-lc", cmd]
|
||||||
|
if capture:
|
||||||
|
return run_capture(args, cwd=cwd, timeout=timeout)
|
||||||
|
return run_stream(args, cwd=cwd, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_args(cmd):
|
||||||
|
return SSH_BASE + ["bash", "-lc", shlex.quote(cmd)]
|
||||||
|
|
||||||
|
|
||||||
|
def exec_ssh_cmd(cmd, timeout=120, capture=False):
|
||||||
|
"""Run a remote command through SSH. Streams output by default."""
|
||||||
|
args = ssh_args(cmd)
|
||||||
|
if capture:
|
||||||
|
ok, stdout, stderr = run_capture(args, cwd=SCRIPT_DIR, timeout=timeout)
|
||||||
|
else:
|
||||||
|
ok, stdout, stderr = run_stream(args, cwd=SCRIPT_DIR, timeout=timeout)
|
||||||
|
if not ok:
|
||||||
|
log_error(f"SSH命令失败: {cmd}")
|
||||||
|
if stderr:
|
||||||
|
log_error(stderr.strip())
|
||||||
|
return ok, stdout.strip(), stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
def scp_upload(local_path, remote_path, recursive=False):
|
def scp_upload(local_path, remote_path, recursive=False):
|
||||||
"""通过SCP上传文件或目录"""
|
"""Upload a file or directory through SCP with streamed logs."""
|
||||||
r_flag = "-r" if recursive else ""
|
args = SCP_BASE.copy()
|
||||||
scp_cmd = f'scp -o ConnectTimeout=10 -o BatchMode=yes {r_flag} "{local_path}" {USERNAME}@{SERVER_IP}:{remote_path}'
|
if recursive:
|
||||||
try:
|
args.append("-r")
|
||||||
result = subprocess.run(
|
args.extend([str(local_path), f"{USERNAME}@{SERVER_IP}:{remote_path}"])
|
||||||
scp_cmd,
|
ok, _, stderr = run_stream(args, cwd=SCRIPT_DIR, timeout=300)
|
||||||
shell=True,
|
if not ok:
|
||||||
capture_output=True,
|
log_error(f"SCP上传失败: {stderr.strip()}")
|
||||||
text=True,
|
return ok
|
||||||
timeout=120 # 上传文件给更长时间
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
log_error(f"SCP上传失败: {result.stderr}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
log_error("SCP上传超时")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
log_error(f"SCP上传异常: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_env():
|
def check_env():
|
||||||
"""检查本地环境"""
|
|
||||||
log_info("检查本地环境...")
|
log_info("检查本地环境...")
|
||||||
|
success, _, err = run_command("npm --version", timeout=30)
|
||||||
# 检查npm
|
|
||||||
success, _, _ = run_command("npm --version")
|
|
||||||
if not success:
|
if not success:
|
||||||
log_error("未找到npm命令,请先安装Node.js")
|
log_error("未找到 npm 命令,请先安装 Node.js")
|
||||||
|
if err:
|
||||||
|
log_error(err)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
log_info("环境检查通过")
|
log_info("环境检查通过")
|
||||||
|
|
||||||
|
|
||||||
def build_project():
|
def build_project():
|
||||||
"""构建项目"""
|
|
||||||
log_info("开始构建项目...")
|
log_info("开始构建项目...")
|
||||||
|
|
||||||
# 切换到项目目录
|
|
||||||
os.chdir(SCRIPT_DIR)
|
os.chdir(SCRIPT_DIR)
|
||||||
|
|
||||||
# 清理旧构建
|
|
||||||
if DIST_DIR.exists():
|
if DIST_DIR.exists():
|
||||||
shutil.rmtree(DIST_DIR)
|
shutil.rmtree(DIST_DIR)
|
||||||
log_info("已清理旧的dist目录")
|
log_info("已清理旧的 dist 目录")
|
||||||
|
|
||||||
# 执行构建(不捕获输出,直接显示)
|
|
||||||
log_info("执行: npm run build")
|
log_info("执行: npm run build")
|
||||||
success, _, _ = run_command("npm run build", capture=False)
|
success, _, err = run_command("npm run build", cwd=SCRIPT_DIR, capture=False, timeout=600)
|
||||||
if not success:
|
if not success:
|
||||||
log_error("项目构建失败")
|
log_error("项目构建失败")
|
||||||
|
if err:
|
||||||
|
log_error(err)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not DIST_DIR.exists():
|
||||||
|
log_error("dist 目录不存在")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
log_info("项目构建成功")
|
log_info("项目构建成功")
|
||||||
|
|
||||||
# 检查dist目录
|
|
||||||
if not DIST_DIR.exists():
|
|
||||||
log_error("dist目录不存在")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def deploy():
|
def deploy():
|
||||||
"""部署到服务器"""
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
release_path = f"{RELEASES_DIR}/{timestamp}"
|
release_path = f"{RELEASES_DIR}/{timestamp}"
|
||||||
|
|
||||||
log_info(f"准备部署版本: {timestamp}")
|
log_info(f"准备部署版本: {timestamp}")
|
||||||
|
|
||||||
# 1. 创建远程目录
|
|
||||||
log_info("创建远程目录...")
|
log_info("创建远程目录...")
|
||||||
success, _, err = exec_ssh_cmd(f"mkdir -p {release_path}")
|
success, _, err = exec_ssh_cmd(f"mkdir -p {shlex.quote(release_path)}", timeout=60)
|
||||||
if not success:
|
if not success:
|
||||||
log_error(f"创建远程目录失败: {err}")
|
log_error(f"创建远程目录失败: {err}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 2. 上传文件
|
|
||||||
log_info("上传文件到服务器...")
|
log_info("上传文件到服务器...")
|
||||||
for item in DIST_DIR.iterdir():
|
for item in DIST_DIR.iterdir():
|
||||||
log_info(f" 上传: {item.name}")
|
log_info(f" 上传: {item.name}")
|
||||||
if item.is_file():
|
if item.is_file():
|
||||||
if not scp_upload(item, f"{release_path}/"):
|
ok = scp_upload(item, f"{release_path}/")
|
||||||
log_error("文件上传失败")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
if not scp_upload(item, f"{release_path}/", recursive=True):
|
ok = scp_upload(item, f"{release_path}/", recursive=True)
|
||||||
log_error("目录上传失败")
|
if not ok:
|
||||||
sys.exit(1)
|
log_error("文件上传失败")
|
||||||
|
sys.exit(1)
|
||||||
log_info("文件上传成功")
|
log_info("文件上传成功")
|
||||||
|
|
||||||
# 3. 设置权限
|
|
||||||
log_info("设置文件权限...")
|
log_info("设置文件权限...")
|
||||||
exec_ssh_cmd(f"chmod -R 755 {release_path}")
|
success, _, err = exec_ssh_cmd(f"chmod -R 755 {shlex.quote(release_path)}", timeout=120)
|
||||||
|
if not success:
|
||||||
|
log_error(f"设置文件权限失败: {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# 4. 原子切换软链接
|
|
||||||
log_info("切换服务版本...")
|
log_info("切换服务版本...")
|
||||||
# 检查目标路径是否为普通目录
|
switch_script = f"""
|
||||||
exec_ssh_cmd(f"if [ -d '{LINK_PATH}' ] && [ ! -L '{LINK_PATH}' ]; then mv '{LINK_PATH}' '{LINK_PATH}_backup_$(date +%s)'; fi")
|
set -e
|
||||||
# 创建软链接
|
release_path={shlex.quote(release_path)}
|
||||||
success, _, err = exec_ssh_cmd(f"ln -snf '{release_path}' '{LINK_PATH}'")
|
link_path={shlex.quote(LINK_PATH)}
|
||||||
|
|
||||||
|
if [ -d "$link_path" ] && [ ! -L "$link_path" ]; then
|
||||||
|
backup_path="${{link_path}}_backup_$(date +%s)"
|
||||||
|
echo "当前路径是普通目录,备份到: $backup_path"
|
||||||
|
mv "$link_path" "$backup_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sfn "$release_path" "$link_path"
|
||||||
|
echo "当前版本指向: $(readlink "$link_path")"
|
||||||
|
"""
|
||||||
|
success, _, err = exec_ssh_cmd(switch_script, timeout=120)
|
||||||
if not success:
|
if not success:
|
||||||
log_error(f"切换版本失败: {err}")
|
log_error(f"切换版本失败: {err}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
log_info(f"部署完成!当前版本指向: {release_path}")
|
log_info(f"部署完成,当前版本指向: {release_path}")
|
||||||
|
|
||||||
# 5. 清理旧版本
|
|
||||||
clean_old_releases()
|
clean_old_releases()
|
||||||
|
|
||||||
|
|
||||||
def clean_old_releases():
|
def clean_old_releases():
|
||||||
"""清理旧版本,只保留最近的N个"""
|
log_info(f"清理旧版本(保留最近 {MAX_RELEASES} 个)...")
|
||||||
log_info(f"清理旧版本(保留最近{MAX_RELEASES}个)...")
|
clean_script = f"""
|
||||||
clean_cmd = f"cd {RELEASES_DIR} && ls -t | tail -n +{MAX_RELEASES + 1} | xargs -I {{}} rm -rf {{}}"
|
set -e
|
||||||
exec_ssh_cmd(clean_cmd)
|
mkdir -p {shlex.quote(RELEASES_DIR)}
|
||||||
|
cd {shlex.quote(RELEASES_DIR)}
|
||||||
|
old_releases=$(ls -1dt */ 2>/dev/null | tail -n +{MAX_RELEASES + 1} || true)
|
||||||
|
if [ -n "$old_releases" ]; then
|
||||||
|
echo "$old_releases" | xargs -r rm -rf
|
||||||
|
echo "$old_releases" | sed 's/^/已删除旧版本: /'
|
||||||
|
else
|
||||||
|
echo "没有需要清理的旧版本"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
success, _, err = exec_ssh_cmd(clean_script, timeout=120)
|
||||||
|
if not success:
|
||||||
|
log_warn(f"清理旧版本失败: {err}")
|
||||||
|
|
||||||
|
|
||||||
def rollback():
|
def rollback():
|
||||||
"""回滚到上一个版本"""
|
|
||||||
log_info("开始回滚操作...")
|
log_info("开始回滚操作...")
|
||||||
|
success, current_link, err = exec_ssh_cmd(f"readlink {shlex.quote(LINK_PATH)}", timeout=60, capture=True)
|
||||||
# 获取当前指向的版本
|
if not success:
|
||||||
success, current_link, _ = exec_ssh_cmd(f"readlink {LINK_PATH}")
|
log_error(f"读取当前版本失败: {err}")
|
||||||
|
sys.exit(1)
|
||||||
log_info(f"当前版本: {current_link}")
|
log_info(f"当前版本: {current_link}")
|
||||||
|
|
||||||
# 获取上一个版本目录
|
find_prev_script = f"""
|
||||||
success, prev_version, _ = exec_ssh_cmd(
|
set -e
|
||||||
f"ls -dt {RELEASES_DIR}/* | grep -v '{current_link}' | head -n 1"
|
current_link={shlex.quote(current_link)}
|
||||||
)
|
ls -dt {shlex.quote(RELEASES_DIR)}/* 2>/dev/null | grep -v "$current_link" | head -n 1
|
||||||
|
"""
|
||||||
if not prev_version:
|
success, prev_version, err = exec_ssh_cmd(find_prev_script, timeout=60, capture=True)
|
||||||
|
if not success or not prev_version:
|
||||||
log_error("没有找到可回滚的历史版本")
|
log_error("没有找到可回滚的历史版本")
|
||||||
|
if err:
|
||||||
|
log_error(err)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
log_info(f"回滚目标版本: {prev_version}")
|
log_info(f"回滚目标版本: {prev_version}")
|
||||||
exec_ssh_cmd(f"ln -snf {prev_version} {LINK_PATH}")
|
success, _, err = exec_ssh_cmd(
|
||||||
log_info("回滚成功!")
|
f"ln -sfn {shlex.quote(prev_version)} {shlex.quote(LINK_PATH)} && readlink {shlex.quote(LINK_PATH)}",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
log_error(f"回滚失败: {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
log_info("回滚成功")
|
||||||
|
|
||||||
|
|
||||||
def print_usage():
|
def print_usage():
|
||||||
"""打印使用说明"""
|
print(
|
||||||
print("""
|
"""
|
||||||
用法: python deploy.py [命令]
|
用法: python deploy.py [命令]
|
||||||
|
|
||||||
命令:
|
命令:
|
||||||
@@ -243,11 +324,11 @@ def print_usage():
|
|||||||
示例:
|
示例:
|
||||||
python deploy.py # 部署
|
python deploy.py # 部署
|
||||||
python deploy.py rollback # 回滚
|
python deploy.py rollback # 回滚
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""主函数"""
|
|
||||||
command = sys.argv[1] if len(sys.argv) > 1 else "deploy"
|
command = sys.argv[1] if len(sys.argv) > 1 else "deploy"
|
||||||
|
|
||||||
if command == "rollback":
|
if command == "rollback":
|
||||||
|
|||||||
@@ -1,78 +1,45 @@
|
|||||||
/**
|
import { streamAiScene } from './aiRuntime';
|
||||||
* AI 服务模块
|
|
||||||
* 封装 OpenRouter API 调用
|
|
||||||
*/
|
|
||||||
|
|
||||||
const API_KEY = "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";
|
const runScene = async (sceneCode, inputs, callbacks = {}) => {
|
||||||
const BASE_URL = "https://openrouter.ai/api/v1/chat/completions";
|
const result = await streamAiScene({
|
||||||
|
sceneCode,
|
||||||
/**
|
inputs,
|
||||||
* 调用 AI API
|
onStart: callbacks.onStart,
|
||||||
* @param {string} prompt - 用户提示
|
onDelta: callbacks.onDelta,
|
||||||
* @param {string} systemMsg - 系统消息
|
onDone: callbacks.onDone,
|
||||||
* @returns {Promise<string>} AI 响应内容
|
onError: callbacks.onError
|
||||||
*/
|
});
|
||||||
const fetchAI = async (prompt, systemMsg) => {
|
return result.output;
|
||||||
try {
|
|
||||||
const response = await fetch(BASE_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${API_KEY}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "deepseek/deepseek-chat-v3-0324:free",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemMsg },
|
|
||||||
{ role: "user", content: prompt }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.choices[0].message.content;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('AI API Error:', error);
|
|
||||||
return "(AI 暂时陷入了沉思,请稍后再试)";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const analyzeLifeEvent = async (event, callbacks = {}) => {
|
||||||
* 分析生命事件
|
return runScene('life_healing', {
|
||||||
* @param {Object} event - 事件对象 { title, time, content }
|
mode: 'life_event_analysis',
|
||||||
* @returns {Promise<string>} AI 分析反馈
|
prompt: `请分析这段人生事件,并给出温和、具体、可执行的反馈。\n标题:${event.title || ''}\n时间:${event.time || ''}\n内容:${event.content || ''}`,
|
||||||
*/
|
title: event.title,
|
||||||
export const analyzeLifeEvent = async (event) => {
|
time: event.time,
|
||||||
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
|
content: event.content
|
||||||
const prompt = `事件标题:${event.title}\n时间:${event.time}\n内容:${event.content}`;
|
}, callbacks);
|
||||||
return fetchAI(prompt, system);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const generateEpicScript = async (params, events = [], callbacks = {}) => {
|
||||||
* 生成爽文剧本
|
return runScene('script_generate', {
|
||||||
* @param {Object} params - 参数对象 { theme, style, length, character }
|
prompt: params.theme,
|
||||||
* @param {Array} events - 生命事件数组
|
theme: params.theme,
|
||||||
* @returns {Promise<string>} 生成的剧本内容
|
style: params.style,
|
||||||
*/
|
length: params.length,
|
||||||
export const generateEpicScript = async (params, events = []) => {
|
events,
|
||||||
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。剧本必须包含起承转合,使用【标题】标记段落。`;
|
character: params.character,
|
||||||
|
useSocialInsights: params.useSocialInsights
|
||||||
const charInfo = `姓名:${params.character.nickname}, 性格:${params.character.mbti}, 兴趣:${params.character.hobbies?.join(',') || ''}, 星座:${params.character.zodiac}`;
|
}, callbacks);
|
||||||
const eventSummary = events.map(e => e.title).join(', ');
|
|
||||||
|
|
||||||
const prompt = `角色信息:${charInfo}\n过往经历关键词:${eventSummary}\n用户指定主题:${params.theme}\n指定风格:${params.style}\n篇幅要求:${params.length}\n\n请以此创作一段热血、精彩的人生剧本。`;
|
|
||||||
|
|
||||||
return fetchAI(prompt, system);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const generatePath = async (script, callbacks = {}) => {
|
||||||
* 生成实现路径
|
return runScene('life_healing', {
|
||||||
* @param {string} script - 剧本内容
|
mode: 'path_generate',
|
||||||
* @returns {Promise<string>} 生成的路径内容
|
prompt: `请把下面的人生剧本拆解成现实中可执行的路径,按阶段输出。\n\n${script || ''}`,
|
||||||
*/
|
script
|
||||||
export const generatePath = async (script) => {
|
}, callbacks);
|
||||||
const system = "你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。使用【阶段名称】加上具体建议。务必客观、可执行。";
|
|
||||||
return fetchAI(script, system);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
const parseSseFrame = (frame) => {
|
||||||
|
const event = { type: 'message', data: '' };
|
||||||
|
frame.split(/\r?\n/).forEach((line) => {
|
||||||
|
if (line.startsWith('event:')) event.type = line.slice(6).trim();
|
||||||
|
if (line.startsWith('data:')) event.data += line.slice(5).trim();
|
||||||
|
});
|
||||||
|
if (!event.data) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return { type: event.type, content: event.data };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamAiScene = async ({
|
||||||
|
sceneCode,
|
||||||
|
inputs = {},
|
||||||
|
onStart,
|
||||||
|
onDelta,
|
||||||
|
onDone,
|
||||||
|
onError
|
||||||
|
}) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sceneCode, inputs })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const message = `AI流式请求失败(${response.status})`;
|
||||||
|
onError?.(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
const consumeText = (text) => {
|
||||||
|
buffer += text;
|
||||||
|
const frames = buffer.split(/\r?\n\r?\n/);
|
||||||
|
buffer = frames.pop() || '';
|
||||||
|
frames.forEach((frame) => {
|
||||||
|
const event = parseSseFrame(frame);
|
||||||
|
if (!event) return;
|
||||||
|
if (event.type === 'start') {
|
||||||
|
onStart?.(event);
|
||||||
|
} else if (event.type === 'delta') {
|
||||||
|
const delta = event.content || '';
|
||||||
|
output += delta;
|
||||||
|
onDelta?.(delta, output, event);
|
||||||
|
} else if (event.type === 'done') {
|
||||||
|
onDone?.(event, output);
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
const message = event.message || event.code || 'AI流式请求失败';
|
||||||
|
onError?.(message, event);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
consumeText(decoder.decode(value, { stream: true }));
|
||||||
|
}
|
||||||
|
consumeText(decoder.decode());
|
||||||
|
if (buffer.trim()) consumeText('\n\n');
|
||||||
|
return { output };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
streamAiScene
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
const { getSelectedScript, selectedPath, setPath, loadPath, deletePath, selectedScriptId } = useStore();
|
const { getSelectedScript, selectedPath, setPath, loadPath, deletePath, selectedScriptId } = useStore();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [streamPath, setStreamPath] = useState('');
|
||||||
|
|
||||||
const selectedScript = getSelectedScript();
|
const selectedScript = getSelectedScript();
|
||||||
|
|
||||||
@@ -37,8 +38,12 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = await generatePath(selectedScript.content);
|
setStreamPath('');
|
||||||
|
const path = await generatePath(selectedScript.content, {
|
||||||
|
onDelta: (_delta, output) => setStreamPath(output)
|
||||||
|
});
|
||||||
await setPath(path, selectedScriptId);
|
await setPath(path, selectedScriptId);
|
||||||
|
setStreamPath('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate path:', error);
|
console.error('Failed to generate path:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,7 +82,8 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const pathSteps = parsePathSteps(selectedPath);
|
const visiblePath = streamPath || selectedPath;
|
||||||
|
const pathSteps = parsePathSteps(visiblePath);
|
||||||
|
|
||||||
// 无剧本时显示提示
|
// 无剧本时显示提示
|
||||||
if (!selectedScript) {
|
if (!selectedScript) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GlassCard, GlassButton, GlassInput, GlassSelect } from '../components/u
|
|||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import useStore from '../store/useStore';
|
import useStore from '../store/useStore';
|
||||||
import { scriptStyles, scriptLengths } from '../utils/constants';
|
import { scriptStyles, scriptLengths } from '../utils/constants';
|
||||||
|
import { generateEpicScript } from '../services/ai';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScriptView 组件
|
* ScriptView 组件
|
||||||
@@ -37,6 +38,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
const [style, setStyle] = useState(scriptStyles[0].value);
|
const [style, setStyle] = useState(scriptStyles[0].value);
|
||||||
const [length, setLength] = useState(scriptLengths[0].value);
|
const [length, setLength] = useState(scriptLengths[0].value);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [streamContent, setStreamContent] = useState('');
|
||||||
|
|
||||||
// 编辑模态框状态
|
// 编辑模态框状态
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
@@ -62,16 +64,23 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 直接调用后端创建接口,由后端调用AI生成
|
setStreamContent('');
|
||||||
|
const content = await generateEpicScript(
|
||||||
|
{ theme, style, length, character: registrationData },
|
||||||
|
lifeEvents,
|
||||||
|
{ onDelta: (_delta, output) => setStreamContent(output) }
|
||||||
|
);
|
||||||
await addScript({
|
await addScript({
|
||||||
theme,
|
theme,
|
||||||
style,
|
style,
|
||||||
length,
|
length,
|
||||||
|
content,
|
||||||
character: registrationData,
|
character: registrationData,
|
||||||
events: lifeEvents
|
events: lifeEvents
|
||||||
});
|
});
|
||||||
|
|
||||||
setTheme('');
|
setTheme('');
|
||||||
|
setStreamContent('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate script:', error);
|
console.error('Failed to generate script:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,16 +123,24 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setStreamContent('');
|
||||||
|
const content = await generateEpicScript(
|
||||||
|
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
|
||||||
|
lifeEvents,
|
||||||
|
{ onDelta: (_delta, output) => setStreamContent(output) }
|
||||||
|
);
|
||||||
await updateScript({
|
await updateScript({
|
||||||
id: editingScript.id,
|
id: editingScript.id,
|
||||||
theme: editForm.theme,
|
theme: editForm.theme,
|
||||||
style: editForm.style,
|
style: editForm.style,
|
||||||
length: editForm.length,
|
length: editForm.length,
|
||||||
|
content,
|
||||||
character: registrationData,
|
character: registrationData,
|
||||||
events: lifeEvents,
|
events: lifeEvents,
|
||||||
regenerateContent: true // 标记需要重新生成AI内容
|
regenerateContent: false
|
||||||
});
|
});
|
||||||
closeEditModal();
|
closeEditModal();
|
||||||
|
setStreamContent('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update script:', error);
|
console.error('Failed to update script:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -289,7 +306,22 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
{/* 右侧剧本展示区 */}
|
{/* 右侧剧本展示区 */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
{selectedScript ? (
|
{isLoading && streamContent ? (
|
||||||
|
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
|
||||||
|
<div className="prose prose-invert max-w-none">
|
||||||
|
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-2xl font-serif text-orange-200">{theme}</h4>
|
||||||
|
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">正在生成</p>
|
||||||
|
</div>
|
||||||
|
<BookOpen className="text-white/20" />
|
||||||
|
</div>
|
||||||
|
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm">
|
||||||
|
{streamContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
) : selectedScript ? (
|
||||||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in relative group" padding="lg">
|
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in relative group" padding="lg">
|
||||||
{/* 编辑按钮 */}
|
{/* 编辑按钮 */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Plus, Wind, Sparkles, Pencil, Trash2 } from 'lucide-react';
|
|||||||
import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components/ui';
|
import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components/ui';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import useStore from '../store/useStore';
|
import useStore from '../store/useStore';
|
||||||
|
import { analyzeLifeEvent } from '../services/ai';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化 AI 反馈内容的组件
|
* 格式化 AI 反馈内容的组件
|
||||||
@@ -91,6 +92,7 @@ const TimelineView = () => {
|
|||||||
// 模态框状态
|
// 模态框状态
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [streamFeedback, setStreamFeedback] = useState('');
|
||||||
|
|
||||||
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
||||||
const [editingEventId, setEditingEventId] = useState(null);
|
const [editingEventId, setEditingEventId] = useState(null);
|
||||||
@@ -111,6 +113,7 @@ const TimelineView = () => {
|
|||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
setEditingEventId(null);
|
setEditingEventId(null);
|
||||||
setEventForm({ title: '', time: '', content: '' });
|
setEventForm({ title: '', time: '', content: '' });
|
||||||
|
setStreamFeedback('');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ const TimelineView = () => {
|
|||||||
time: event.time || '',
|
time: event.time || '',
|
||||||
content: event.content || ''
|
content: event.content || ''
|
||||||
});
|
});
|
||||||
|
setStreamFeedback(event.aiFeedback || '');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ const TimelineView = () => {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingEventId(null);
|
setEditingEventId(null);
|
||||||
setEventForm({ title: '', time: '', content: '' });
|
setEventForm({ title: '', time: '', content: '' });
|
||||||
|
setStreamFeedback('');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,16 +154,23 @@ const TimelineView = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setStreamFeedback('');
|
||||||
|
const aiFeedback = await analyzeLifeEvent(eventForm, {
|
||||||
|
onDelta: (_delta, output) => setStreamFeedback(output)
|
||||||
|
});
|
||||||
|
|
||||||
if (editingEventId) {
|
if (editingEventId) {
|
||||||
// 编辑模式:调用更新接口
|
// 编辑模式:调用更新接口
|
||||||
await updateLifeEvent({
|
await updateLifeEvent({
|
||||||
id: editingEventId,
|
id: editingEventId,
|
||||||
...eventForm
|
...eventForm,
|
||||||
|
aiFeedback
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 新增模式:调用添加接口
|
// 新增模式:调用添加接口
|
||||||
await addLifeEvent({
|
await addLifeEvent({
|
||||||
...eventForm
|
...eventForm,
|
||||||
|
aiFeedback
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +323,15 @@ const TimelineView = () => {
|
|||||||
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
||||||
rows={5}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
|
{streamFeedback && (
|
||||||
|
<div className="ai-glow-card p-4 rounded-2xl bg-orange-200/[0.02] border border-orange-200/5">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sparkles className="w-3 h-3 text-orange-200" />
|
||||||
|
<span className="text-[9px] uppercase tracking-[0.2em] text-orange-200/60 font-bold">实时疗愈反馈</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs italic text-white/50 leading-loose whitespace-pre-wrap">{streamFeedback}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ VITE_APP_ENV=dev
|
|||||||
# VITE_API_BASE_URL=http://localhost:19089/api
|
# VITE_API_BASE_URL=http://localhost:19089/api
|
||||||
# VITE_WS_URL=ws://localhost:19089/ws
|
# VITE_WS_URL=ws://localhost:19089/ws
|
||||||
# 直连后端服务(不经过 Nginx)
|
# 直连后端服务(不经过 Nginx)
|
||||||
VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
# VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
||||||
VITE_WS_URL=ws://101.200.208.45:19089/ws
|
# VITE_WS_URL=ws://101.200.208.45:19089/ws
|
||||||
# 测试环境
|
# 测试环境
|
||||||
# VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
|
VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
|
||||||
# VITE_WS_URL=wss://lifescript.happylifeos.com/ws
|
VITE_WS_URL=wss://lifescript.happylifeos.com/ws
|
||||||
VITE_DEBUG=true
|
VITE_DEBUG=true
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# 测试环境配置(小程序体验版)
|
# 测试环境配置(小程序体验版)
|
||||||
VITE_APP_ENV=test
|
VITE_APP_ENV=test
|
||||||
# 直连后端服务(不经过 Nginx)
|
# 直连后端服务(不经过 Nginx)
|
||||||
VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
# VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
||||||
VITE_WS_URL=ws://101.200.208.45:19089/ws
|
# VITE_WS_URL=ws://101.200.208.45:19089/ws
|
||||||
|
# VITE_DEBUG=true
|
||||||
|
# 测试环境
|
||||||
|
VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
|
||||||
|
VITE_WS_URL=wss://lifescript.happylifeos.com/ws
|
||||||
VITE_DEBUG=true
|
VITE_DEBUG=true
|
||||||
+18
-12
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||||
import { useAppStore } from './stores/app.js'
|
|
||||||
import { logRuntimeEnv } from './services/request.js'
|
import { logRuntimeEnv } from './services/request.js'
|
||||||
import analytics from './services/analytics.js'
|
import analytics from './services/analytics.js'
|
||||||
|
|
||||||
@@ -9,20 +8,27 @@ const statusBarHeight = ref(0)
|
|||||||
const safeAreaTop = ref(0)
|
const safeAreaTop = ref(0)
|
||||||
const safeAreaBottom = ref(0)
|
const safeAreaBottom = ref(0)
|
||||||
|
|
||||||
onLaunch(async () => {
|
const hydrateSafeArea = () => {
|
||||||
|
try {
|
||||||
|
const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
|
||||||
|
statusBarHeight.value = windowInfo.statusBarHeight || 20
|
||||||
|
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
|
||||||
|
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
||||||
|
uni.setStorageSync('statusBarHeight', statusBarHeight.value)
|
||||||
|
uni.setStorageSync('safeAreaTop', safeAreaTop.value)
|
||||||
|
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
|
||||||
|
} catch (error) {
|
||||||
|
statusBarHeight.value = 20
|
||||||
|
safeAreaTop.value = 20
|
||||||
|
safeAreaBottom.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLaunch(() => {
|
||||||
console.log('App Launch')
|
console.log('App Launch')
|
||||||
analytics.initAnalytics()
|
analytics.initAnalytics()
|
||||||
logRuntimeEnv('app:onLaunch')
|
logRuntimeEnv('app:onLaunch')
|
||||||
const store = useAppStore()
|
hydrateSafeArea()
|
||||||
await store.initialize()
|
|
||||||
|
|
||||||
const windowInfo = uni.getWindowInfo()
|
|
||||||
statusBarHeight.value = windowInfo.statusBarHeight || 20
|
|
||||||
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
|
|
||||||
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
|
||||||
uni.setStorageSync('statusBarHeight', statusBarHeight.value)
|
|
||||||
uni.setStorageSync('safeAreaTop', safeAreaTop.value)
|
|
||||||
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
|||||||
@@ -336,7 +336,11 @@ const loadEvent = async (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assistWrite = async () => {
|
const assistWrite = async () => {
|
||||||
const result = await store.assistEventWriting({ ...form })
|
const result = await store.assistEventWriting({ ...form }, {
|
||||||
|
onDelta: (_delta, output) => {
|
||||||
|
form.content = output
|
||||||
|
}
|
||||||
|
})
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' })
|
uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
import { computed, ref, onMounted, watch } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import * as lifePathService from '../../services/lifePath.js'
|
import * as lifePathService from '../../services/lifePath.js'
|
||||||
|
import { streamAiScene } from '../../services/aiRuntime.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
@@ -74,11 +75,55 @@ const loadPath = async (scriptId) => {
|
|||||||
const createPlaceholderPath = async (scriptId) => {
|
const createPlaceholderPath = async (scriptId) => {
|
||||||
const script = selectedScript.value || {}
|
const script = selectedScript.value || {}
|
||||||
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
|
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
|
||||||
|
let generatedText = ''
|
||||||
|
try {
|
||||||
|
const streamRes = await streamAiScene({
|
||||||
|
sceneCode: 'life_healing',
|
||||||
|
inputs: {
|
||||||
|
mode: 'path_generate',
|
||||||
|
prompt: `请把下面的人生剧本拆解成现实中可执行的路径,按阶段输出。\n\n${script.content || script.summary || ''}`,
|
||||||
|
script: script.content || script.summary || ''
|
||||||
|
},
|
||||||
|
onDelta: (_delta, output) => {
|
||||||
|
generatedText = output
|
||||||
|
pathData.value = {
|
||||||
|
id: `stream-${scriptId}`,
|
||||||
|
scriptId,
|
||||||
|
title,
|
||||||
|
description: output,
|
||||||
|
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
|
||||||
|
phase: `阶段${index + 1}`,
|
||||||
|
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
|
||||||
|
desc: line,
|
||||||
|
content: line,
|
||||||
|
done: index === 0
|
||||||
|
})),
|
||||||
|
progress: 8,
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
generatedText = streamRes.output || generatedText
|
||||||
|
} catch (error) {
|
||||||
|
generatedText = ''
|
||||||
|
}
|
||||||
const steps = [
|
const steps = [
|
||||||
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
|
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
|
||||||
{ phase: '阶段2', task: '建立习惯', desc: '选择一个最小行动,每天稳定推进。', content: '选择一个最小行动,每天稳定推进。', done: false },
|
{ phase: '阶段2', task: '建立习惯', desc: '选择一个最小行动,每天稳定推进。', content: '选择一个最小行动,每天稳定推进。', done: false },
|
||||||
{ phase: '阶段3', task: '复盘迭代', desc: '每周回看进展,根据现实反馈调整路径。', content: '每周回看进展,根据现实反馈调整路径。', done: false }
|
{ phase: '阶段3', task: '复盘迭代', desc: '每周回看进展,根据现实反馈调整路径。', content: '每周回看进展,根据现实反馈调整路径。', done: false }
|
||||||
]
|
]
|
||||||
|
if (generatedText) {
|
||||||
|
const generatedSteps = generatedText.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
|
||||||
|
phase: `阶段${index + 1}`,
|
||||||
|
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
|
||||||
|
desc: line,
|
||||||
|
content: line,
|
||||||
|
done: index === 0
|
||||||
|
}))
|
||||||
|
if (generatedSteps.length) {
|
||||||
|
steps.splice(0, steps.length, ...generatedSteps)
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await lifePathService.createPath({
|
const res = await lifePathService.createPath({
|
||||||
scriptId,
|
scriptId,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="chat-bubble system">
|
<view class="chat-bubble system">
|
||||||
<text>心愿实现中……</text>
|
<text>心愿实现中……</text>
|
||||||
|
<text v-if="streamContent" class="stream-preview">{{ streamContent }}</text>
|
||||||
<text class="bubble-time">{{ currentMessageTime }}</text>
|
<text class="bubble-time">{{ currentMessageTime }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -145,11 +146,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
import * as socialImport from '../../services/socialImport.js'
|
import * as socialImport from '../../services/socialImport.js'
|
||||||
|
import * as epicScriptService from '../../services/epicScript.js'
|
||||||
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
||||||
|
import { transcribeAudio } from '../../services/asr.js'
|
||||||
|
import { streamAiScene } from '../../services/aiRuntime.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const pagePath = '/pages/main/ScriptView'
|
const pagePath = '/pages/main/ScriptView'
|
||||||
@@ -160,6 +164,7 @@ const generationStartedAt = ref(0)
|
|||||||
const currentResult = ref(null)
|
const currentResult = ref(null)
|
||||||
const currentMessageTime = ref('')
|
const currentMessageTime = ref('')
|
||||||
const currentResultTime = ref('')
|
const currentResultTime = ref('')
|
||||||
|
const streamContent = ref('')
|
||||||
const generating = ref(false)
|
const generating = ref(false)
|
||||||
const remainingCount = ref(3)
|
const remainingCount = ref(3)
|
||||||
const style = ref('career')
|
const style = ref('career')
|
||||||
@@ -167,6 +172,9 @@ const randomRecommendations = ref([])
|
|||||||
const useSocialInsights = ref(true)
|
const useSocialInsights = ref(true)
|
||||||
const confirmedInsights = ref([])
|
const confirmedInsights = ref([])
|
||||||
const ttsPlayer = useTtsPlayer({ pagePath })
|
const ttsPlayer = useTtsPlayer({ pagePath })
|
||||||
|
let recorderManager = null
|
||||||
|
let recordStartedAt = 0
|
||||||
|
let recordCancelled = false
|
||||||
|
|
||||||
const fallbackRecommendations = [
|
const fallbackRecommendations = [
|
||||||
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
|
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
|
||||||
@@ -186,7 +194,7 @@ const socialInsightCopy = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const voiceCopy = computed(() => {
|
const voiceCopy = computed(() => {
|
||||||
if (voiceState.value === 'pressing') return '松开后开始实现心愿'
|
if (voiceState.value === 'pressing') return '松开后识别心愿'
|
||||||
if (voiceState.value === 'recognizing') return '正在识别你的心愿……'
|
if (voiceState.value === 'recognizing') return '正在识别你的心愿……'
|
||||||
if (voiceState.value === 'error') return '语音暂不可用,可以先输入文字'
|
if (voiceState.value === 'error') return '语音暂不可用,可以先输入文字'
|
||||||
return '按住说话,即刻如愿'
|
return '按住说话,即刻如愿'
|
||||||
@@ -272,30 +280,145 @@ const shuffleInspirations = async () => {
|
|||||||
|
|
||||||
const startVoicePress = () => {
|
const startVoicePress = () => {
|
||||||
if (generating.value) return
|
if (generating.value) return
|
||||||
|
if (!recorderManager) {
|
||||||
|
voiceState.value = 'error'
|
||||||
|
analytics.track('script_voice_recognize_fail', {
|
||||||
|
reason: 'recorder_unavailable'
|
||||||
|
}, { eventType: 'script', pagePath })
|
||||||
|
uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
|
||||||
|
setTimeout(() => {
|
||||||
|
if (voiceState.value === 'error') voiceState.value = 'idle'
|
||||||
|
}, 1800)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordCancelled = false
|
||||||
|
recordStartedAt = Date.now()
|
||||||
voiceState.value = 'pressing'
|
voiceState.value = 'pressing'
|
||||||
analytics.track('script_voice_press_start', {}, { eventType: 'script', pagePath })
|
analytics.track('script_voice_press_start', {}, { eventType: 'script', pagePath })
|
||||||
|
recorderManager.start({
|
||||||
|
duration: 60000,
|
||||||
|
sampleRate: 16000,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
encodeBitRate: 48000,
|
||||||
|
format: 'mp3'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelVoicePress = () => {
|
const cancelVoicePress = () => {
|
||||||
voiceState.value = 'idle'
|
recordCancelled = true
|
||||||
|
if (recorderManager && voiceState.value === 'pressing') {
|
||||||
|
recorderManager.stop()
|
||||||
|
} else {
|
||||||
|
voiceState.value = 'idle'
|
||||||
|
}
|
||||||
|
analytics.track('script_voice_record_cancel', {}, { eventType: 'script', pagePath })
|
||||||
}
|
}
|
||||||
|
|
||||||
const endVoicePress = async () => {
|
const endVoicePress = async () => {
|
||||||
if (voiceState.value !== 'pressing') return
|
if (voiceState.value !== 'pressing') return
|
||||||
analytics.track('script_voice_press_end', {}, { eventType: 'script', pagePath })
|
analytics.track('script_voice_press_end', {}, { eventType: 'script', pagePath })
|
||||||
voiceState.value = 'recognizing'
|
voiceState.value = 'recognizing'
|
||||||
|
recorderManager?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVoiceRecognizeSuccess = (text, durationMs) => {
|
||||||
|
wishText.value = text
|
||||||
|
voiceState.value = 'idle'
|
||||||
|
analytics.track('script_voice_recognize_success', {
|
||||||
|
text_length: text.length,
|
||||||
|
duration_ms: durationMs || 0
|
||||||
|
}, { eventType: 'script', pagePath })
|
||||||
|
uni.showToast({ title: '识别成功,可修改后发送', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVoiceRecognizeFail = (reason, message = '语音识别失败,请重试') => {
|
||||||
|
voiceState.value = 'error'
|
||||||
|
analytics.track('script_voice_recognize_fail', {
|
||||||
|
reason
|
||||||
|
}, { eventType: 'script', pagePath })
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
voiceState.value = 'error'
|
if (voiceState.value === 'error') voiceState.value = 'idle'
|
||||||
analytics.track('script_voice_recognize_fail', {
|
}, 1800)
|
||||||
reason: 'speech_recognition_not_configured'
|
}
|
||||||
}, { eventType: 'script', pagePath })
|
|
||||||
uni.showToast({ title: '语音识别暂未配置,请先输入文字', icon: 'none' })
|
|
||||||
|
|
||||||
setTimeout(() => {
|
const setupRecorder = () => {
|
||||||
if (voiceState.value === 'error') voiceState.value = 'idle'
|
if (!uni.getRecorderManager) return
|
||||||
}, 1800)
|
recorderManager = uni.getRecorderManager()
|
||||||
}, 300)
|
recorderManager.onStop(async (res) => {
|
||||||
|
if (recordCancelled) {
|
||||||
|
voiceState.value = 'idle'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const duration = Date.now() - recordStartedAt
|
||||||
|
if (!res?.tempFilePath || duration < 500) {
|
||||||
|
handleVoiceRecognizeFail('record_too_short', '说话时间太短,请重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voiceState.value = 'recognizing'
|
||||||
|
try {
|
||||||
|
const response = await transcribeAudio(res.tempFilePath)
|
||||||
|
const text = response?.data?.text?.trim()
|
||||||
|
if (!text) {
|
||||||
|
handleVoiceRecognizeFail('empty_result', '没有识别到内容,请重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleVoiceRecognizeSuccess(text, response?.data?.durationMs)
|
||||||
|
} catch (error) {
|
||||||
|
handleVoiceRecognizeFail(error?.message || error?.errMsg || 'upload_failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
recorderManager.onError((error) => {
|
||||||
|
handleVoiceRecognizeFail(error?.errMsg || 'recorder_error', '录音失败,请检查麦克风权限')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateScriptByStream = async (text) => {
|
||||||
|
const profile = store.userProfile || store.registrationData || {}
|
||||||
|
const characterInfo = epicScriptService.buildCharacterInfo(profile)
|
||||||
|
const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile)
|
||||||
|
const streamRes = await streamAiScene({
|
||||||
|
sceneCode: 'script_generate',
|
||||||
|
inputs: {
|
||||||
|
prompt: text,
|
||||||
|
style: style.value,
|
||||||
|
length: 'medium',
|
||||||
|
useSocialInsights: useSocialInsights.value
|
||||||
|
},
|
||||||
|
onDelta: (_delta, output) => {
|
||||||
|
streamContent.value = output
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const content = streamRes.output?.trim()
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('AI 流式输出为空')
|
||||||
|
}
|
||||||
|
const saveRes = await store.createScript({
|
||||||
|
title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
|
||||||
|
theme: text,
|
||||||
|
style: style.value,
|
||||||
|
length: 'medium',
|
||||||
|
content,
|
||||||
|
plotJson: {
|
||||||
|
mode: 'inspiration',
|
||||||
|
prompt: text,
|
||||||
|
source: 'mini-program-stream',
|
||||||
|
fullContent: content
|
||||||
|
},
|
||||||
|
characterInfo,
|
||||||
|
lifeEventsSummary,
|
||||||
|
useSocialInsights: useSocialInsights.value
|
||||||
|
})
|
||||||
|
if (!saveRes.success) {
|
||||||
|
throw new Error(saveRes.error || '保存剧本失败')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
script: saveRes.data,
|
||||||
|
remainingCount: remainingCount.value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitWish = async (source = 'text') => {
|
const submitWish = async (source = 'text') => {
|
||||||
@@ -310,6 +433,7 @@ const submitWish = async (source = 'text') => {
|
|||||||
currentMessageTime.value = formatMessageTime()
|
currentMessageTime.value = formatMessageTime()
|
||||||
generationStartedAt.value = Date.now()
|
generationStartedAt.value = Date.now()
|
||||||
generating.value = true
|
generating.value = true
|
||||||
|
streamContent.value = ''
|
||||||
ttsPlayer.reset()
|
ttsPlayer.reset()
|
||||||
viewState.value = 'generating'
|
viewState.value = 'generating'
|
||||||
analytics.track('script_generation_progress_view', {
|
analytics.track('script_generation_progress_view', {
|
||||||
@@ -317,12 +441,19 @@ const submitWish = async (source = 'text') => {
|
|||||||
prompt_length: text.length
|
prompt_length: text.length
|
||||||
}, { eventType: 'script', pagePath })
|
}, { eventType: 'script', pagePath })
|
||||||
|
|
||||||
const res = await store.generateScriptFromInspiration({
|
let res
|
||||||
prompt: text,
|
try {
|
||||||
style: style.value,
|
res = await generateScriptByStream(text)
|
||||||
length: 'medium',
|
} catch (streamError) {
|
||||||
useSocialInsights: useSocialInsights.value
|
analytics.track('script_generate_stream_fail', {
|
||||||
})
|
source,
|
||||||
|
error: streamError?.message || 'stream_failed'
|
||||||
|
}, { eventType: 'script', pagePath })
|
||||||
|
res = {
|
||||||
|
success: false,
|
||||||
|
error: streamError?.message || 'AI 流式生成失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
generating.value = false
|
generating.value = false
|
||||||
|
|
||||||
@@ -398,8 +529,16 @@ const loadConfirmedInsights = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
|
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
|
||||||
|
setupRecorder()
|
||||||
loadConfirmedInsights()
|
loadConfirmedInsights()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (recorderManager && voiceState.value === 'pressing') {
|
||||||
|
recordCancelled = true
|
||||||
|
recorderManager.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -753,6 +892,16 @@ onMounted(() => {
|
|||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-preview {
|
||||||
|
display: block;
|
||||||
|
max-height: 320rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-bubble.done {
|
.chat-bubble.done {
|
||||||
border-color: rgba(192, 132, 252, 0.42);
|
border-color: rgba(192, 132, 252, 0.42);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,40 +16,72 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import { logRuntimeEnv } from '../../services/request.js'
|
import { logRuntimeEnv } from '../../services/request.js'
|
||||||
|
|
||||||
const statusBarHeight = ref(20)
|
const statusBarHeight = ref(20)
|
||||||
const safeAreaBottom = ref(0)
|
const safeAreaBottom = ref(0)
|
||||||
|
const routed = ref(false)
|
||||||
|
let bootTimer = null
|
||||||
|
let fallbackTimer = null
|
||||||
|
|
||||||
onMounted(() => {
|
const routeOnce = (url, meta = {}) => {
|
||||||
logRuntimeEnv('splash:onLoad')
|
if (routed.value) return
|
||||||
// 使用新的推荐 API 替代已弃用的 getSystemInfoSync
|
routed.value = true
|
||||||
const windowInfo = uni.getWindowInfo()
|
if (bootTimer) clearTimeout(bootTimer)
|
||||||
statusBarHeight.value = windowInfo.statusBarHeight || 20
|
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||||||
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
console.log('[AUTH] route', { target: url, ...meta })
|
||||||
setTimeout(async () => {
|
uni.reLaunch({ url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const readWindowInfo = () => {
|
||||||
|
try {
|
||||||
|
const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
|
||||||
|
statusBarHeight.value = windowInfo.statusBarHeight || 20
|
||||||
|
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
||||||
|
} catch (error) {
|
||||||
|
statusBarHeight.value = uni.getStorageSync('statusBarHeight') || 20
|
||||||
|
safeAreaBottom.value = uni.getStorageSync('safeAreaBottom') || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveInitialRoute = async () => {
|
||||||
|
try {
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const session = await store.restoreSession()
|
const session = await store.restoreSession()
|
||||||
|
|
||||||
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
||||||
const target = session.hasProfile ? '/pages/main/index' : '/pages/onboarding/index'
|
const target = session.hasProfile ? '/pages/main/index' : '/pages/onboarding/index'
|
||||||
console.log('[AUTH] route', { target, reason: session.reason, hasProfile: session.hasProfile })
|
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
|
||||||
uni.reLaunch({ url: target })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status === store.SESSION_STATUS.ERROR) {
|
if (session.status === store.SESSION_STATUS.ERROR) {
|
||||||
console.log('[AUTH] route', { target: '/pages/login/index', reason: session.reason })
|
|
||||||
uni.showToast({ title: '服务连接异常,请稍后重试', icon: 'none' })
|
uni.showToast({ title: '服务连接异常,请稍后重试', icon: 'none' })
|
||||||
uni.reLaunch({ url: '/pages/login/index' })
|
routeOnce('/pages/login/index', { reason: session.reason })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[AUTH] route', { target: '/pages/login/index', reason: session.reason })
|
routeOnce('/pages/login/index', { reason: session.reason || 'unauthenticated' })
|
||||||
uni.reLaunch({ url: '/pages/login/index' })
|
} catch (error) {
|
||||||
}, 2000)
|
console.error('[AUTH] splash route failed', error)
|
||||||
|
routeOnce('/pages/login/index', { reason: 'splash_exception', message: error?.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
logRuntimeEnv('splash:onLoad')
|
||||||
|
readWindowInfo()
|
||||||
|
bootTimer = setTimeout(resolveInitialRoute, 500)
|
||||||
|
fallbackTimer = setTimeout(() => {
|
||||||
|
routeOnce('/pages/login/index', { reason: 'splash_route_timeout' })
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (bootTimer) clearTimeout(bootTimer)
|
||||||
|
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,7 +95,6 @@ onMounted(() => {
|
|||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 标题字体 - Cinzel (原型标准) */
|
|
||||||
.app-name.font-serif {
|
.app-name.font-serif {
|
||||||
font-family: 'Cinzel', 'Inter', serif;
|
font-family: 'Cinzel', 'Inter', serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { getEnvValue } from '../config/env.js'
|
||||||
|
|
||||||
|
const API_BASE_URL = getEnvValue('API_BASE_URL')
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const token = uni.getStorageSync('access_token')
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeChunk = (arrayBuffer) => {
|
||||||
|
if (typeof TextDecoder !== 'undefined') {
|
||||||
|
return new TextDecoder('utf-8').decode(arrayBuffer)
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(arrayBuffer)
|
||||||
|
let binary = ''
|
||||||
|
bytes.forEach(byte => {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(binary))
|
||||||
|
} catch (error) {
|
||||||
|
return binary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSseFrame = (frame) => {
|
||||||
|
const event = { type: 'message', data: '' }
|
||||||
|
frame.split(/\r?\n/).forEach((line) => {
|
||||||
|
if (line.startsWith('event:')) event.type = line.slice(6).trim()
|
||||||
|
if (line.startsWith('data:')) event.data += line.slice(5).trim()
|
||||||
|
})
|
||||||
|
if (!event.data) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.data)
|
||||||
|
} catch (error) {
|
||||||
|
return { type: event.type, content: event.data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone, onError }) => {
|
||||||
|
let buffer = ''
|
||||||
|
let output = ''
|
||||||
|
let closed = false
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const failStream = (message, event) => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
onError?.(message, event)
|
||||||
|
reject(new Error(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestTask = uni.request({
|
||||||
|
url: `${API_BASE_URL}/ai/runtime/stream`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { sceneCode, inputs },
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...getAuthHeader()
|
||||||
|
},
|
||||||
|
enableChunked: true,
|
||||||
|
timeout: 120000,
|
||||||
|
success: (res) => {
|
||||||
|
if (closed) return
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
const message = res.data?.message || 'AI流式请求失败'
|
||||||
|
failStream(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof res.data === 'string' && res.data) {
|
||||||
|
consumeText(res.data, failStream)
|
||||||
|
}
|
||||||
|
if (buffer.trim()) {
|
||||||
|
consumeText('\n\n', failStream)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
resolve({ output })
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
failStream(error.errMsg || 'AI流式请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
requestTask?.onChunkReceived?.((res) => {
|
||||||
|
try {
|
||||||
|
consumeText(decodeChunk(res.data), failStream)
|
||||||
|
} catch (error) {
|
||||||
|
failStream(error.message || 'AI流式请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function consumeText(text, failStream) {
|
||||||
|
buffer += text
|
||||||
|
const frames = buffer.split(/\r?\n\r?\n/)
|
||||||
|
buffer = frames.pop() || ''
|
||||||
|
frames.forEach((frame) => {
|
||||||
|
const event = parseSseFrame(frame)
|
||||||
|
if (!event) return
|
||||||
|
if (event.type === 'start') {
|
||||||
|
onStart?.(event)
|
||||||
|
} else if (event.type === 'delta') {
|
||||||
|
output += event.content || ''
|
||||||
|
onDelta?.(event.content || '', output, event)
|
||||||
|
} else if (event.type === 'done') {
|
||||||
|
onDone?.(event, output)
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
const message = event.message || event.code || 'AI流式请求失败'
|
||||||
|
failStream(message, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (buffer.trim() && text === '') {
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
streamAiScene
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { upload } from './request.js'
|
||||||
|
|
||||||
|
export const transcribeAudio = (filePath) => {
|
||||||
|
return upload('/asr/transcribe', filePath, {}, 'file')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
transcribeAudio
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { get, post, put, del } from './request.js'
|
import { get, post, put, del } from './request.js'
|
||||||
|
import { streamAiScene } from './aiRuntime.js'
|
||||||
|
|
||||||
export const getEventList = async () => {
|
export const getEventList = async () => {
|
||||||
const response = await get('/lifeEvent/list')
|
const response = await get('/lifeEvent/list')
|
||||||
@@ -37,8 +38,22 @@ export const deleteEvent = async (id) => {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assistEventWriting = async (eventData = {}) => {
|
export const assistEventWriting = async (eventData = {}, callbacks = {}) => {
|
||||||
return post('/lifeEvent/ai-assist', eventData)
|
const result = await streamAiScene({
|
||||||
|
sceneCode: 'life_healing',
|
||||||
|
inputs: {
|
||||||
|
mode: 'life_event_assist',
|
||||||
|
prompt: `请帮我优化这段人生事件记录,保留真实细节,让表达更清晰温柔。\n标题:${eventData.title || ''}\n时间:${eventData.time || eventData.eventDateText || ''}\n内容:${eventData.content || ''}`,
|
||||||
|
...eventData
|
||||||
|
},
|
||||||
|
...callbacks
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
content: result.output,
|
||||||
|
tags: eventData.tags || []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatAboutEvent = async (eventData = {}) => {
|
export const chatAboutEvent = async (eventData = {}) => {
|
||||||
|
|||||||
@@ -171,9 +171,9 @@ const deleteEvent = async (id) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistEventWriting = async (eventData) => {
|
const assistEventWriting = async (eventData, callbacks = {}) => {
|
||||||
try {
|
try {
|
||||||
const res = await lifeEventService.assistEventWriting(eventData)
|
const res = await lifeEventService.assistEventWriting(eventData, callbacks)
|
||||||
return { success: true, data: res.data }
|
return { success: true, data: res.data }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
@@ -223,9 +223,9 @@ const fetchScripts = async () => {
|
|||||||
|
|
||||||
const createScript = async (scriptData) => {
|
const createScript = async (scriptData) => {
|
||||||
try {
|
try {
|
||||||
await epicScriptService.createScript(scriptData)
|
const res = await epicScriptService.createScript(scriptData)
|
||||||
await fetchScripts()
|
await fetchScripts()
|
||||||
return { success: true }
|
return { success: true, data: res.data }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `t_ai_provider` (
|
||||||
|
`id` varchar(64) NOT NULL COMMENT 'primary key',
|
||||||
|
`provider_code` varchar(64) NOT NULL COMMENT 'provider code',
|
||||||
|
`provider_name` varchar(100) NOT NULL COMMENT 'provider display name',
|
||||||
|
`provider_type` varchar(32) NOT NULL COMMENT 'dify/coze/openai/etc',
|
||||||
|
`base_url` varchar(500) NOT NULL COMMENT 'provider base url',
|
||||||
|
`api_key` text NULL COMMENT 'api key or token',
|
||||||
|
`auth_type` varchar(32) DEFAULT 'bearer' COMMENT 'auth type',
|
||||||
|
`default_headers` text NULL COMMENT 'json headers',
|
||||||
|
`timeout_ms` int DEFAULT 60000 COMMENT 'timeout milliseconds',
|
||||||
|
`is_enabled` tinyint DEFAULT 1 COMMENT '0 disabled, 1 enabled',
|
||||||
|
`description` varchar(500) NULL COMMENT 'description',
|
||||||
|
`create_by` varchar(64) NULL,
|
||||||
|
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_by` varchar(64) NULL,
|
||||||
|
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`is_deleted` tinyint DEFAULT 0,
|
||||||
|
`remarks` varchar(500) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_ai_provider_code` (`provider_code`),
|
||||||
|
KEY `idx_ai_provider_type` (`provider_type`, `is_enabled`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI provider configuration';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_ai_endpoint_config` (
|
||||||
|
`id` varchar(64) NOT NULL COMMENT 'primary key',
|
||||||
|
`endpoint_code` varchar(100) NOT NULL COMMENT 'endpoint code',
|
||||||
|
`endpoint_name` varchar(100) NOT NULL COMMENT 'endpoint display name',
|
||||||
|
`provider_id` varchar(64) NOT NULL COMMENT 'provider id',
|
||||||
|
`endpoint_type` varchar(32) NOT NULL COMMENT 'workflow/chat/completion',
|
||||||
|
`api_path` varchar(300) NULL COMMENT 'provider api path',
|
||||||
|
`workflow_id` varchar(128) NULL COMMENT 'workflow id',
|
||||||
|
`bot_id` varchar(128) NULL COMMENT 'bot id',
|
||||||
|
`model_name` varchar(128) NULL COMMENT 'model name',
|
||||||
|
`response_mode` varchar(32) DEFAULT 'streaming' COMMENT 'streaming/blocking',
|
||||||
|
`request_template` text NULL COMMENT 'json request template with {{var}} placeholders',
|
||||||
|
`default_inputs` text NULL COMMENT 'json default inputs',
|
||||||
|
`custom_headers` text NULL COMMENT 'json custom headers',
|
||||||
|
`timeout_ms` int DEFAULT 60000 COMMENT 'timeout milliseconds',
|
||||||
|
`support_stream` tinyint DEFAULT 1 COMMENT '0 no, 1 yes',
|
||||||
|
`is_enabled` tinyint DEFAULT 1 COMMENT '0 disabled, 1 enabled',
|
||||||
|
`description` varchar(500) NULL COMMENT 'description',
|
||||||
|
`create_by` varchar(64) NULL,
|
||||||
|
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_by` varchar(64) NULL,
|
||||||
|
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`is_deleted` tinyint DEFAULT 0,
|
||||||
|
`remarks` varchar(500) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_ai_endpoint_code` (`endpoint_code`),
|
||||||
|
KEY `idx_ai_endpoint_provider` (`provider_id`, `is_enabled`),
|
||||||
|
KEY `idx_ai_endpoint_type` (`endpoint_type`, `support_stream`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI endpoint and workflow configuration';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_ai_scene_binding` (
|
||||||
|
`id` varchar(64) NOT NULL COMMENT 'primary key',
|
||||||
|
`scene_code` varchar(100) NOT NULL COMMENT 'scene code',
|
||||||
|
`scene_name` varchar(100) NOT NULL COMMENT 'scene display name',
|
||||||
|
`endpoint_id` varchar(64) NOT NULL COMMENT 'endpoint id',
|
||||||
|
`input_schema` text NULL COMMENT 'json schema or field description',
|
||||||
|
`prompt_template` text NULL COMMENT 'reserved prompt template',
|
||||||
|
`required_stream` tinyint DEFAULT 1 COMMENT '0 no, 1 yes',
|
||||||
|
`priority` int DEFAULT 0 COMMENT 'larger value wins',
|
||||||
|
`is_enabled` tinyint DEFAULT 1 COMMENT '0 disabled, 1 enabled',
|
||||||
|
`version` varchar(32) DEFAULT 'v1' COMMENT 'binding version',
|
||||||
|
`description` varchar(500) NULL COMMENT 'description',
|
||||||
|
`create_by` varchar(64) NULL,
|
||||||
|
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_by` varchar(64) NULL,
|
||||||
|
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`is_deleted` tinyint DEFAULT 0,
|
||||||
|
`remarks` varchar(500) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ai_scene_code` (`scene_code`, `is_enabled`, `priority`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI scene to endpoint binding';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_ai_call_log` (
|
||||||
|
`id` varchar(64) NOT NULL COMMENT 'primary key',
|
||||||
|
`scene_code` varchar(100) NULL COMMENT 'scene code',
|
||||||
|
`provider_code` varchar(64) NULL COMMENT 'provider code',
|
||||||
|
`endpoint_code` varchar(100) NULL COMMENT 'endpoint code',
|
||||||
|
`user_id` varchar(64) NULL COMMENT 'user id',
|
||||||
|
`request_id` varchar(64) NULL COMMENT 'request id',
|
||||||
|
`status` varchar(32) NULL COMMENT 'running/success/failed',
|
||||||
|
`input_text` mediumtext NULL COMMENT 'input payload',
|
||||||
|
`output_text` mediumtext NULL COMMENT 'stream output',
|
||||||
|
`error_code` varchar(64) NULL COMMENT 'error code',
|
||||||
|
`error_message` text NULL COMMENT 'error message',
|
||||||
|
`first_token_ms` bigint NULL COMMENT 'first token latency',
|
||||||
|
`duration_ms` bigint NULL COMMENT 'duration milliseconds',
|
||||||
|
`stream_chunks` int DEFAULT 0 COMMENT 'stream delta chunks',
|
||||||
|
`create_by` varchar(64) NULL,
|
||||||
|
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_by` varchar(64) NULL,
|
||||||
|
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`is_deleted` tinyint DEFAULT 0,
|
||||||
|
`remarks` varchar(500) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ai_call_scene_time` (`scene_code`, `create_time`),
|
||||||
|
KEY `idx_ai_call_request` (`request_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI runtime call log';
|
||||||
|
|
||||||
|
INSERT INTO `t_ai_scene_binding`
|
||||||
|
(`id`, `scene_code`, `scene_name`, `endpoint_id`, `input_schema`, `required_stream`, `priority`, `is_enabled`, `version`, `description`)
|
||||||
|
SELECT UUID(), scene_code, scene_name, '', '{"prompt":"string"}', 1, 0, 0, 'v1', 'Reserved scene. Bind an enabled endpoint before use.'
|
||||||
|
FROM (
|
||||||
|
SELECT 'chat' scene_code, 'AI 对话' scene_name UNION ALL
|
||||||
|
SELECT 'script_generate', '剧本生成' UNION ALL
|
||||||
|
SELECT 'short_story_generate', '短篇小说生成' UNION ALL
|
||||||
|
SELECT 'diary_summary', '日记总结' UNION ALL
|
||||||
|
SELECT 'emotion_summary', '情绪总结' UNION ALL
|
||||||
|
SELECT 'emotion_analysis', '情绪分析' UNION ALL
|
||||||
|
SELECT 'life_healing', '人生事件疗愈'
|
||||||
|
) s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `t_ai_scene_binding` b WHERE b.`scene_code` = s.scene_code AND b.`is_deleted` = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding`
|
||||||
|
SET `scene_name` = CASE `scene_code`
|
||||||
|
WHEN 'chat' THEN 'AI 对话'
|
||||||
|
WHEN 'script_generate' THEN '剧本生成'
|
||||||
|
WHEN 'short_story_generate' THEN '短篇小说生成'
|
||||||
|
WHEN 'diary_summary' THEN '日记总结'
|
||||||
|
WHEN 'emotion_summary' THEN '情绪总结'
|
||||||
|
WHEN 'emotion_analysis' THEN '情绪分析'
|
||||||
|
WHEN 'life_healing' THEN '人生事件疗愈'
|
||||||
|
ELSE `scene_name`
|
||||||
|
END
|
||||||
|
WHERE `scene_code` IN ('chat', 'script_generate', 'short_story_generate', 'diary_summary', 'emotion_summary', 'emotion_analysis', 'life_healing')
|
||||||
|
AND `is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `t_ai_provider`
|
||||||
|
(`id`, `provider_code`, `provider_name`, `provider_type`, `base_url`, `api_key`, `auth_type`, `default_headers`, `timeout_ms`, `is_enabled`, `description`)
|
||||||
|
SELECT UUID(), 'coze_default', 'Coze 默认服务商', 'coze', 'https://api.coze.cn', c.`api_token`, 'bearer', NULL,
|
||||||
|
COALESCE(c.`timeout_ms`, 60000), 1, '由旧 AI 配置自动迁移生成'
|
||||||
|
FROM `t_ai_config` c
|
||||||
|
WHERE c.`is_deleted` = 0
|
||||||
|
AND c.`provider` = 'coze'
|
||||||
|
AND c.`api_token` IS NOT NULL
|
||||||
|
AND c.`api_token` <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `t_ai_provider` p WHERE p.`provider_code` = 'coze_default' AND p.`is_deleted` = 0
|
||||||
|
)
|
||||||
|
ORDER BY c.`create_time` DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `t_ai_provider`
|
||||||
|
(`id`, `provider_code`, `provider_name`, `provider_type`, `base_url`, `api_key`, `auth_type`, `default_headers`, `timeout_ms`, `is_enabled`, `description`)
|
||||||
|
SELECT UUID(), 'dify_default', 'Dify 默认服务商', 'dify', 'http://49.232.138.53/v1', 'app-MqQOx09gCu9zzlKMpeLqHQHv',
|
||||||
|
'bearer', NULL, 60000, 1, '由 Dify 平台接口文档初始化生成'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `t_ai_provider` p WHERE p.`provider_code` = 'dify_default' AND p.`is_deleted` = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `t_ai_endpoint_config`
|
||||||
|
(`id`, `endpoint_code`, `endpoint_name`, `provider_id`, `endpoint_type`, `api_path`, `workflow_id`, `bot_id`, `response_mode`,
|
||||||
|
`request_template`, `default_inputs`, `custom_headers`, `timeout_ms`, `support_stream`, `is_enabled`, `description`)
|
||||||
|
SELECT UUID(), c.`config_key`, c.`config_name`, p.`id`,
|
||||||
|
CASE WHEN c.`workflow_id` IS NOT NULL AND c.`workflow_id` <> '' THEN 'workflow' ELSE 'chat' END,
|
||||||
|
CASE
|
||||||
|
WHEN c.`api_base_url` LIKE '%/v1/workflow/stream_run' THEN '/v1/workflow/stream_run'
|
||||||
|
WHEN c.`api_base_url` LIKE '%/v3/chat' THEN '/v3/chat'
|
||||||
|
ELSE '/v1/workflow/stream_run'
|
||||||
|
END,
|
||||||
|
c.`workflow_id`, c.`bot_id`, 'streaming',
|
||||||
|
NULL, '{}', c.`custom_headers`, COALESCE(c.`timeout_ms`, 60000), COALESCE(c.`support_stream`, 1), c.`is_enabled`,
|
||||||
|
CONCAT('由旧 AI 配置迁移:', c.`config_key`)
|
||||||
|
FROM `t_ai_config` c
|
||||||
|
JOIN `t_ai_provider` p ON p.`provider_code` = 'coze_default' AND p.`is_deleted` = 0
|
||||||
|
WHERE c.`is_deleted` = 0
|
||||||
|
AND c.`provider` = 'coze'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `t_ai_endpoint_config` e
|
||||||
|
WHERE e.`endpoint_code` COLLATE utf8mb4_unicode_ci = c.`config_key` COLLATE utf8mb4_unicode_ci
|
||||||
|
AND e.`is_deleted` = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `t_ai_endpoint_config`
|
||||||
|
(`id`, `endpoint_code`, `endpoint_name`, `provider_id`, `endpoint_type`, `api_path`, `workflow_id`, `bot_id`, `response_mode`,
|
||||||
|
`request_template`, `default_inputs`, `custom_headers`, `timeout_ms`, `support_stream`, `is_enabled`, `description`)
|
||||||
|
SELECT UUID(), endpoint_code, endpoint_name, p.`id`, 'chat', '/chat-messages', NULL, NULL, 'streaming',
|
||||||
|
NULL, '{}', NULL, 60000, 1, 1, '由 Dify 平台接口文档初始化生成'
|
||||||
|
FROM (
|
||||||
|
SELECT 'dify.script_generate.chat_messages' endpoint_code, 'Dify 剧本生成对话接口' endpoint_name UNION ALL
|
||||||
|
SELECT 'dify.short_story_generate.chat_messages', 'Dify 短篇小说生成对话接口'
|
||||||
|
) seed
|
||||||
|
JOIN `t_ai_provider` p ON p.`provider_code` = 'dify_default' AND p.`is_deleted` = 0
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `t_ai_endpoint_config` e WHERE e.`endpoint_code` = seed.endpoint_code AND e.`is_deleted` = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.chat.default' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 聊天工作流'
|
||||||
|
WHERE s.`scene_code` = 'chat' AND s.`is_deleted` = 0;
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'dify.script_generate.chat_messages' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定 Dify 剧本生成接口'
|
||||||
|
WHERE s.`scene_code` = 'script_generate' AND s.`is_deleted` = 0;
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'dify.short_story_generate.chat_messages' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定 Dify 短篇小说生成接口'
|
||||||
|
WHERE s.`scene_code` = 'short_story_generate' AND s.`is_deleted` = 0;
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.user.dairy.summary' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 日记总结工作流'
|
||||||
|
WHERE s.`scene_code` = 'diary_summary' AND s.`is_deleted` = 0;
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.user.life.state' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 用户状态分析工作流'
|
||||||
|
WHERE s.`scene_code` = 'emotion_analysis' AND s.`is_deleted` = 0;
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.summary.default' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 情绪总结工作流'
|
||||||
|
WHERE s.`scene_code` = 'emotion_summary' AND s.`is_deleted` = 0;
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.user.dairy.summary' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 日记总结工作流,作为情绪总结兜底'
|
||||||
|
WHERE s.`scene_code` = 'emotion_summary'
|
||||||
|
AND s.`is_deleted` = 0
|
||||||
|
AND (s.`endpoint_id` IS NULL OR s.`endpoint_id` = '' OR s.`is_enabled` = 0);
|
||||||
|
|
||||||
|
UPDATE `t_ai_scene_binding` s
|
||||||
|
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.user.dairy.summary' AND e.`is_deleted` = 0
|
||||||
|
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 人生事件疗愈工作流'
|
||||||
|
WHERE s.`scene_code` = 'life_healing' AND s.`is_deleted` = 0;
|
||||||
@@ -64,7 +64,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'list',
|
path: 'list',
|
||||||
name: 'AiConfigList',
|
name: 'AiConfigList',
|
||||||
component: () => import('@/views/aiconfig/AiConfigList.vue'),
|
component: () => import('@/views/aiconfig/AiRoutingList.vue'),
|
||||||
meta: { title: 'AI配置列表' }
|
meta: { title: 'AI配置列表' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -186,3 +186,91 @@ export const CURRENCY_OPTIONS = [
|
|||||||
{ label: '美元', value: 'USD' },
|
{ label: '美元', value: 'USD' },
|
||||||
{ label: '人民币', value: 'CNY' }
|
{ label: '人民币', value: 'CNY' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export interface AiProvider {
|
||||||
|
id?: string
|
||||||
|
providerCode: string
|
||||||
|
providerName: string
|
||||||
|
providerType: string
|
||||||
|
baseUrl: string
|
||||||
|
apiKey?: string
|
||||||
|
authType?: string
|
||||||
|
defaultHeaders?: string
|
||||||
|
timeoutMs?: number
|
||||||
|
isEnabled?: number
|
||||||
|
description?: string
|
||||||
|
createTime?: string
|
||||||
|
updateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEndpointConfig {
|
||||||
|
id?: string
|
||||||
|
endpointCode: string
|
||||||
|
endpointName: string
|
||||||
|
providerId: string
|
||||||
|
endpointType: string
|
||||||
|
apiPath?: string
|
||||||
|
workflowId?: string
|
||||||
|
botId?: string
|
||||||
|
modelName?: string
|
||||||
|
responseMode?: string
|
||||||
|
requestTemplate?: string
|
||||||
|
defaultInputs?: string
|
||||||
|
customHeaders?: string
|
||||||
|
timeoutMs?: number
|
||||||
|
supportStream?: number
|
||||||
|
isEnabled?: number
|
||||||
|
description?: string
|
||||||
|
createTime?: string
|
||||||
|
updateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSceneBinding {
|
||||||
|
id?: string
|
||||||
|
sceneCode: string
|
||||||
|
sceneName: string
|
||||||
|
endpointId: string
|
||||||
|
inputSchema?: string
|
||||||
|
promptTemplate?: string
|
||||||
|
requiredStream?: number
|
||||||
|
priority?: number
|
||||||
|
isEnabled?: number
|
||||||
|
version?: string
|
||||||
|
description?: string
|
||||||
|
createTime?: string
|
||||||
|
updateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiCallLog {
|
||||||
|
id: string
|
||||||
|
sceneCode?: string
|
||||||
|
providerCode?: string
|
||||||
|
endpointCode?: string
|
||||||
|
userId?: string
|
||||||
|
requestId?: string
|
||||||
|
status?: string
|
||||||
|
inputText?: string
|
||||||
|
outputText?: string
|
||||||
|
errorCode?: string
|
||||||
|
errorMessage?: string
|
||||||
|
firstTokenMs?: number
|
||||||
|
durationMs?: number
|
||||||
|
streamChunks?: number
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiRuntimeRequest {
|
||||||
|
sceneCode: string
|
||||||
|
userId?: string
|
||||||
|
inputs: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiRuntimeTestResponse {
|
||||||
|
sceneCode: string
|
||||||
|
status: string
|
||||||
|
output?: string
|
||||||
|
durationMs?: number
|
||||||
|
streamChunks?: number
|
||||||
|
errorCode?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { envConfig } from '@/config/env'
|
||||||
|
|
||||||
|
export interface AiStreamEvent {
|
||||||
|
type: 'start' | 'delta' | 'done' | 'error' | string
|
||||||
|
content?: string
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
seq?: number
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamAiSceneOptions {
|
||||||
|
sceneCode: string
|
||||||
|
inputs?: Record<string, any>
|
||||||
|
onStart?: (event: AiStreamEvent) => void
|
||||||
|
onDelta?: (delta: string, output: string, event: AiStreamEvent) => void
|
||||||
|
onDone?: (event: AiStreamEvent, output: string) => void
|
||||||
|
onError?: (message: string, event?: AiStreamEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSseFrame = (frame: string): AiStreamEvent | null => {
|
||||||
|
const parsed = { type: 'message', data: '' }
|
||||||
|
frame.split(/\r?\n/).forEach((line) => {
|
||||||
|
if (line.startsWith('event:')) parsed.type = line.slice(6).trim()
|
||||||
|
if (line.startsWith('data:')) parsed.data += line.slice(5).trim()
|
||||||
|
})
|
||||||
|
if (!parsed.data) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(parsed.data)
|
||||||
|
} catch {
|
||||||
|
return { type: parsed.type, content: parsed.data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const streamAiScene = async ({
|
||||||
|
sceneCode,
|
||||||
|
inputs = {},
|
||||||
|
onStart,
|
||||||
|
onDelta,
|
||||||
|
onDone,
|
||||||
|
onError
|
||||||
|
}: StreamAiSceneOptions): Promise<{ output: string }> => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
const response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sceneCode, inputs })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const message = `AI流式请求失败(${response.status})`
|
||||||
|
onError?.(message)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let buffer = ''
|
||||||
|
let output = ''
|
||||||
|
|
||||||
|
const consumeText = (text: string) => {
|
||||||
|
buffer += text
|
||||||
|
const frames = buffer.split(/\r?\n\r?\n/)
|
||||||
|
buffer = frames.pop() || ''
|
||||||
|
frames.forEach((frame) => {
|
||||||
|
const event = parseSseFrame(frame)
|
||||||
|
if (!event) return
|
||||||
|
if (event.type === 'start') {
|
||||||
|
onStart?.(event)
|
||||||
|
} else if (event.type === 'delta') {
|
||||||
|
const delta = event.content || ''
|
||||||
|
output += delta
|
||||||
|
onDelta?.(delta, output, event)
|
||||||
|
} else if (event.type === 'done') {
|
||||||
|
onDone?.(event, output)
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
const message = event.message || event.code || 'AI流式请求失败'
|
||||||
|
onError?.(message, event)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
consumeText(decoder.decode(value, { stream: true }))
|
||||||
|
}
|
||||||
|
consumeText(decoder.decode())
|
||||||
|
if (buffer.trim()) consumeText('\n\n')
|
||||||
|
return { output }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
streamAiScene
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { envConfig } from '@/config/env'
|
|||||||
export interface WebSocketMessage {
|
export interface WebSocketMessage {
|
||||||
messageId?: string
|
messageId?: string
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING'
|
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING' | 'AI_STREAM_START' | 'AI_STREAM_DELTA' | 'AI_STREAM_DONE' | 'AI_STREAM_ERROR' | 'AI_STREAM_EVENT'
|
||||||
content: string
|
content: string
|
||||||
senderId: string
|
senderId: string
|
||||||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||||||
|
|||||||
@@ -469,6 +469,60 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
await processMessageQueue()
|
await processMessageQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => {
|
||||||
|
const messageId = wsMessage.messageId || `ai-stream-${Date.now()}`
|
||||||
|
let message = messages.value.find(m => m.id === messageId)
|
||||||
|
if (!message) {
|
||||||
|
await processMessageQueue()
|
||||||
|
message = messages.value.find(m => m.id === messageId)
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
addMessage({
|
||||||
|
id: messageId,
|
||||||
|
content: '',
|
||||||
|
type: 'ai',
|
||||||
|
conversationId: wsMessage.conversationId || currentSession.value?.id,
|
||||||
|
timestamp: wsMessage.createTime || new Date().toISOString()
|
||||||
|
})
|
||||||
|
await processMessageQueue()
|
||||||
|
message = messages.value.find(m => m.id === messageId)
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAiStreamMessage = async (wsMessage: WebSocketMessage) => {
|
||||||
|
const message = await ensureStreamingAiMessage(wsMessage)
|
||||||
|
if (!message) return
|
||||||
|
|
||||||
|
if (wsMessage.type === 'AI_STREAM_START') {
|
||||||
|
isTyping.value = true
|
||||||
|
message.status = 'sending'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsMessage.type === 'AI_STREAM_DELTA') {
|
||||||
|
isTyping.value = false
|
||||||
|
message.content += wsMessage.content || ''
|
||||||
|
message.status = 'sending'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsMessage.type === 'AI_STREAM_DONE') {
|
||||||
|
isTyping.value = false
|
||||||
|
message.status = 'sent'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsMessage.type === 'AI_STREAM_ERROR') {
|
||||||
|
isTyping.value = false
|
||||||
|
message.status = 'failed'
|
||||||
|
message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用'
|
||||||
|
if (!message.content) {
|
||||||
|
message.content = message.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket消息处理 - 使用队列处理所有消息
|
// WebSocket消息处理 - 使用队列处理所有消息
|
||||||
let handleWebSocketMessage = async (wsMessage: WebSocketMessage) => {
|
let handleWebSocketMessage = async (wsMessage: WebSocketMessage) => {
|
||||||
console.log('收到WebSocket消息:', wsMessage.type, wsMessage.senderType, '消息ID:', wsMessage.messageId)
|
console.log('收到WebSocket消息:', wsMessage.type, wsMessage.senderType, '消息ID:', wsMessage.messageId)
|
||||||
@@ -484,6 +538,14 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
case 'AI_THINKING':
|
case 'AI_THINKING':
|
||||||
// AI正在思考 - 不修改响应式数据,避免竞态
|
// AI正在思考 - 不修改响应式数据,避免竞态
|
||||||
console.log('⏳ AI正在思考中...')
|
console.log('⏳ AI正在思考中...')
|
||||||
|
isTyping.value = true
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'AI_STREAM_START':
|
||||||
|
case 'AI_STREAM_DELTA':
|
||||||
|
case 'AI_STREAM_DONE':
|
||||||
|
case 'AI_STREAM_ERROR':
|
||||||
|
await handleAiStreamMessage(wsMessage)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'CONNECTION':
|
case 'CONNECTION':
|
||||||
|
|||||||
Reference in New Issue
Block a user