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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 13:25:21 +08:00
parent d77090aa5e
commit 89fc42819d
72 changed files with 4584 additions and 383 deletions
@@ -0,0 +1 @@
{"reason":"owner process exited","timestamp":1779459252481}
+17
View File
@@ -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.
+114
View File
@@ -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服务暂时不可用,请稍后再试。";
} }
} }
@@ -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,11 +156,12 @@ 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);
return convertToResponse(event); return convertToResponse(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,11 +289,12 @@ 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);
return convertToResponse(event); return convertToResponse(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内容");
} }
+878
View File
@@ -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`
+197 -116
View File
@@ -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=shell, shell=False,
capture_output=True, stdout=subprocess.PIPE,
text=True stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
) )
return result.returncode == 0, result.stdout, result.stderr stdout_thread = threading.Thread(target=_reader_thread, args=(process.stdout, stdout_lines))
else: stderr_thread = threading.Thread(target=_reader_thread, args=(process.stderr, stderr_lines))
result = subprocess.run(cmd, cwd=cwd, shell=shell) stdout_thread.start()
return result.returncode == 0, "", "" stderr_thread.start()
except Exception as e: try:
return False, "", str(e) 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) sys.exit(1)
log_info("项目构建成功")
# 检查dist目录
if not DIST_DIR.exists(): if not DIST_DIR.exists():
log_error("dist 目录不存在") log_error("dist 目录不存在")
sys.exit(1) sys.exit(1)
log_info("项目构建成功")
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:
log_error("文件上传失败")
sys.exit(1) 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_cmd = f"cd {RELEASES_DIR} && ls -t | tail -n +{MAX_RELEASES + 1} | xargs -I {{}} rm -rf {{}}" clean_script = f"""
exec_ssh_cmd(clean_cmd) set -e
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":
+34 -67
View File
@@ -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) => {
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 }
]
})
}); });
return result.output;
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 {
+81
View File
@@ -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
};
+8 -2
View File
@@ -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) {
+35 -3
View File
@@ -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
+23 -2
View File
@@ -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 -4
View File
@@ -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
+6 -2
View File
@@ -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
+15 -9
View File
@@ -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 = () => {
console.log('App Launch') try {
analytics.initAnalytics() const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
logRuntimeEnv('app:onLaunch')
const store = useAppStore()
await store.initialize()
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight || 20 statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20 safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0 safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
uni.setStorageSync('statusBarHeight', statusBarHeight.value) uni.setStorageSync('statusBarHeight', statusBarHeight.value)
uni.setStorageSync('safeAreaTop', safeAreaTop.value) uni.setStorageSync('safeAreaTop', safeAreaTop.value)
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value) uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
} catch (error) {
statusBarHeight.value = 20
safeAreaTop.value = 20
safeAreaBottom.value = 0
}
}
onLaunch(() => {
console.log('App Launch')
analytics.initAnalytics()
logRuntimeEnv('app:onLaunch')
hydrateSafeArea()
}) })
onShow(() => { onShow(() => {
+5 -1
View File
@@ -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
+45
View File
@@ -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,
+162 -13
View File
@@ -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 = () => {
recordCancelled = true
if (recorderManager && voiceState.value === 'pressing') {
recorderManager.stop()
} else {
voiceState.value = 'idle' 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()
}
setTimeout(() => { 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' voiceState.value = 'error'
analytics.track('script_voice_recognize_fail', { analytics.track('script_voice_recognize_fail', {
reason: 'speech_recognition_not_configured' reason
}, { eventType: 'script', pagePath }) }, { eventType: 'script', pagePath })
uni.showToast({ title: '语音识别暂未配置,请先输入文字', icon: 'none' }) uni.showToast({ title: message, icon: 'none' })
setTimeout(() => { setTimeout(() => {
if (voiceState.value === 'error') voiceState.value = 'idle' if (voiceState.value === 'error') voiceState.value = 'idle'
}, 1800) }, 1800)
}, 300) }
const setupRecorder = () => {
if (!uni.getRecorderManager) return
recorderManager = uni.getRecorderManager()
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);
} }
+45 -14
View File
@@ -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)
if (fallbackTimer) clearTimeout(fallbackTimer)
console.log('[AUTH] route', { target: url, ...meta })
uni.reLaunch({ url })
}
const readWindowInfo = () => {
try {
const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
statusBarHeight.value = windowInfo.statusBarHeight || 20 statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0 safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
setTimeout(async () => { } 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;
} }
+120
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
import { upload } from './request.js'
export const transcribeAudio = (filePath) => {
return upload('/asr/transcribe', filePath, {}, 'file')
}
export default {
transcribeAudio
}
+17 -2
View File
@@ -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 = {}) => {
+4 -4
View File
@@ -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 }
} }
+232
View File
@@ -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;
+1 -1
View File
@@ -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配置列表' }
} }
] ]
+88
View File
@@ -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
}
+100
View File
@@ -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
}
+1 -1
View File
@@ -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'
+62
View File
@@ -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':