Parcourir la source

前端对话实现

Zhangbw il y a 2 mois
Parent
commit
e68c14bc96

+ 7 - 0
pom.xml

@@ -100,6 +100,7 @@
     <dependencyManagement>
         <dependencies>
 
+
             <!-- SpringBoot的依赖配置-->
             <dependency>
                 <groupId>org.springframework.boot</groupId>
@@ -350,6 +351,12 @@
                 <version>${revision}</version>
             </dependency>
 
+            <dependency>
+                <groupId>org.dromara</groupId>
+                <artifactId>yp-talk</artifactId>
+                <version>${revision}</version>
+            </dependency>
+
             <!--  工作流模块  -->
             <dependency>
                 <groupId>org.dromara</groupId>

+ 5 - 0
ruoyi-admin/pom.xml

@@ -45,6 +45,11 @@
 <!--            <artifactId>mssql-jdbc</artifactId>-->
 <!--        </dependency>-->
 
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>yp-talk</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.dromara</groupId>
             <artifactId>ruoyi-common-doc</artifactId>

+ 3 - 3
ruoyi-admin/src/main/resources/application-dev.yml

@@ -1,7 +1,7 @@
 --- # 监控中心配置
 spring.boot.admin.client:
   # 增加客户端开关
-  enabled: true
+  enabled: false
   url: http://localhost:9090/admin
   instance:
     service-host-type: IP
@@ -96,13 +96,13 @@ spring:
 spring.data:
   redis:
     # 地址
-    host: localhost
+    host: 192.168.150.102
     # 端口,默认为6379
     port: 6379
     # 数据库索引
     database: 0
     # redis 密码必须配置
-    password: ruoyi123
+    password: hat123
     # 连接超时时间
     timeout: 10s
     # 是否开启ssl

+ 9 - 2
ruoyi-admin/src/main/resources/application.yml

@@ -11,7 +11,7 @@ server:
     max-http-post-size: -1
     # 以下的配置会影响buffer,这些buffer会用于服务器连接的IO操作,有点类似netty的池化内存管理
     # 每块buffer的空间大小,越小的空间被利用越充分
-    buffer-size: 512
+    buffer-size: 16384
     # 是否分配的直接内存
     direct-buffers: true
     threads:
@@ -252,7 +252,7 @@ websocket:
 --- # warm-flow工作流配置
 warm-flow:
   # 是否开启工作流,默认true
-  enabled: true
+  enabled: false
   # 是否开启设计器ui
   ui: true
   # 是否显示流程图顶部文字
@@ -261,3 +261,10 @@ warm-flow:
   node-tooltip: true
   # 默认Authorization,如果有多个token,用逗号分隔
   token-name: ${sa-token.token-name},clientid
+
+--- # 讯飞TTS配置
+xfyun:
+  tts:
+    appid: 0b53c170
+    api-key: 3915b5e04c7e118fea615889a1c94794
+    api-secret: ZTZjZmU3YzczMjdmNmQwMTc0ZDU3OTEw

+ 1 - 0
ruoyi-modules/pom.xml

@@ -15,6 +15,7 @@
         <module>ruoyi-job</module>
         <module>ruoyi-system</module>
         <module>ruoyi-workflow</module>
+        <module>yp-talk</module>
     </modules>
 
     <artifactId>ruoyi-modules</artifactId>

+ 55 - 0
ruoyi-modules/yp-talk/pom.xml

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.dromara</groupId>
+        <artifactId>ruoyi-modules</artifactId>
+        <version>${revision}</version>
+    </parent>
+
+    <artifactId>yp-talk</artifactId>
+
+    <properties>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <!-- RuoYi Common SaToken -->
+        <dependency>
+            <groupId>org.dromara</groupId>
+            <artifactId>ruoyi-common-satoken</artifactId>
+        </dependency>
+
+        <!-- WebSocket -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
+
+        <!-- Java WebSocket Client -->
+        <dependency>
+            <groupId>org.java-websocket</groupId>
+            <artifactId>Java-WebSocket</artifactId>
+            <version>1.5.3</version>
+        </dependency>
+
+        <!-- Fastjson2 -->
+        <dependency>
+            <groupId>com.alibaba.fastjson2</groupId>
+            <artifactId>fastjson2</artifactId>
+            <version>2.0.43</version>
+        </dependency>
+
+        <!-- Lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+
+</project>

+ 24 - 0
ruoyi-modules/yp-talk/src/main/java/org/dromara/talk/config/WebSocketConfig.java

@@ -0,0 +1,24 @@
+package org.dromara.talk.config;
+
+import org.dromara.talk.handler.TtsWebSocketHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+@Configuration
+@EnableWebSocket
+public class WebSocketConfig implements WebSocketConfigurer {
+
+    private final TtsWebSocketHandler ttsHandler;
+
+    public WebSocketConfig(TtsWebSocketHandler ttsHandler) {
+        this.ttsHandler = ttsHandler;
+    }
+
+    @Override
+    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+        registry.addHandler(ttsHandler, "/tts")
+                .setAllowedOrigins("*");
+    }
+}

+ 90 - 0
ruoyi-modules/yp-talk/src/main/java/org/dromara/talk/controller/ChatController.java

@@ -0,0 +1,90 @@
+package org.dromara.talk.controller;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.talk.service.ITtsService;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import java.util.concurrent.CompletableFuture;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/talk")
+@CrossOrigin(origins = "*", maxAge = 3600)
+public class ChatController {
+
+    private final ITtsService ttsService;
+
+    @SaIgnore
+    @PostMapping("/message")
+    public Map<String, Object> handleMessage(@RequestBody Map<String, Object> request) {
+        String userMessage = (String) request.get("message");
+        Long agentId = request.get("agentId") != null ?
+            Long.valueOf(request.get("agentId").toString()) : null;
+
+        log.info("收到用户消息: {}, 客服ID: {}", userMessage, agentId);
+
+        // 生成客服回复(后期接入AI)
+        String reply = generateReply(userMessage);
+
+        // 异步合成语音
+        CompletableFuture<String> audioFuture = new CompletableFuture<>();
+
+        ttsService.synthesize(reply, new ITtsService.AudioCallback() {
+            private final java.io.ByteArrayOutputStream audioBytes = new java.io.ByteArrayOutputStream();
+
+            @Override
+            public void onAudio(String base64Audio, int status) {
+                log.info("收到音频数据: status={}, 数据长度={}", status, base64Audio != null ? base64Audio.length() : 0);
+                try {
+                    // 按照讯飞官方demo的方式:先解码base64为字节,然后写入流
+                    byte[] decoded = java.util.Base64.getDecoder().decode(base64Audio);
+                    audioBytes.write(decoded);
+                    log.info("已写入字节数: {}", decoded.length);
+                } catch (Exception e) {
+                    log.error("解码音频数据失败", e);
+                }
+                if (status == 2) {
+                    // 将完整的字节数组重新编码为base64
+                    String finalBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes.toByteArray());
+                    log.info("音频合成完成,原始字节数={}, base64长度={}", audioBytes.size(), finalBase64.length());
+                    audioFuture.complete(finalBase64);
+                }
+            }
+
+            @Override
+            public void onError(int code, String message) {
+                log.error("TTS合成失败: {}", message);
+                audioFuture.complete(null);
+            }
+        });
+
+        // 等待音频合成完成(最多10秒)
+        String audioBase64 = null;
+        try {
+            audioBase64 = audioFuture.get(10, java.util.concurrent.TimeUnit.SECONDS);
+        } catch (Exception e) {
+            log.error("等待音频合成超时", e);
+        }
+
+        // 返回响应
+        Map<String, Object> response = new HashMap<>();
+        response.put("reply", reply);
+        response.put("audio", audioBase64);
+        response.put("timestamp", System.currentTimeMillis());
+
+        log.info("准备返回响应: reply长度={}, audio长度={}", reply != null ? reply.length() : 0, audioBase64 != null ? audioBase64.length() : 0);
+
+        return response;
+    }
+
+    private String generateReply(String userMessage) {
+        // 默认回复(后期接入AI)
+        return "收到您的消息:" + userMessage;
+    }
+}

+ 87 - 0
ruoyi-modules/yp-talk/src/main/java/org/dromara/talk/handler/TtsWebSocketHandler.java

@@ -0,0 +1,87 @@
+package org.dromara.talk.handler;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.talk.service.ITtsService;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+import org.springframework.web.socket.handler.TextWebSocketHandler;
+
+@Slf4j
+@Component
+public class TtsWebSocketHandler extends TextWebSocketHandler {
+
+    private final ITtsService ttsService;
+
+    public TtsWebSocketHandler(ITtsService ttsService) {
+        this.ttsService = ttsService;
+    }
+
+    @Override
+    public void afterConnectionEstablished(WebSocketSession session) {
+        log.info("TTS WebSocket连接建立: {}", session.getId());
+    }
+
+    @Override
+    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
+        try {
+            JSONObject request = JSON.parseObject(message.getPayload());
+            String text = request.getString("text");
+
+            if (text == null || text.trim().isEmpty()) {
+                sendError(session, -1, "文本内容不能为空");
+                return;
+            }
+
+            ttsService.synthesize(text, new ITtsService.AudioCallback() {
+                @Override
+                public void onAudio(String base64Audio, int status) {
+                    try {
+                        JSONObject response = new JSONObject();
+                        response.put("code", 0);
+                        response.put("audio", base64Audio);
+                        response.put("status", status);
+
+                        session.sendMessage(new TextMessage(response.toJSONString()));
+                    } catch (Exception e) {
+                        log.error("发送音频数据失败", e);
+                    }
+                }
+
+                @Override
+                public void onError(int code, String msg) {
+                    sendError(session, code, msg);
+                }
+            });
+
+        } catch (Exception e) {
+            log.error("处理TTS请求失败", e);
+            sendError(session, -1, "处理请求失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
+        log.info("TTS WebSocket连接关闭: {}, 状态: {}", session.getId(), status);
+    }
+
+    @Override
+    public void handleTransportError(WebSocketSession session, Throwable exception) {
+        log.error("TTS WebSocket传输错误: {}", session.getId(), exception);
+    }
+
+    private void sendError(WebSocketSession session, int code, String message) {
+        try {
+            JSONObject response = new JSONObject();
+            response.put("code", code);
+            response.put("message", message);
+
+            session.sendMessage(new TextMessage(response.toJSONString()));
+        } catch (Exception e) {
+            log.error("发送错误消息失败", e);
+        }
+    }
+}

+ 36 - 0
ruoyi-modules/yp-talk/src/main/java/org/dromara/talk/service/ITtsService.java

@@ -0,0 +1,36 @@
+package org.dromara.talk.service;
+
+/**
+ * TTS语音合成服务接口
+ */
+public interface ITtsService {
+
+    /**
+     * 合成语音
+     *
+     * @param text 要合成的文本
+     * @param callback 音频回调接口
+     */
+    void synthesize(String text, AudioCallback callback);
+
+    /**
+     * 音频回调接口
+     */
+    interface AudioCallback {
+        /**
+         * 接收音频数据
+         *
+         * @param base64Audio Base64编码的音频数据
+         * @param status 状态(1=进行中,2=完成)
+         */
+        void onAudio(String base64Audio, int status);
+
+        /**
+         * 错误回调
+         *
+         * @param code 错误码
+         * @param message 错误信息
+         */
+        void onError(int code, String message);
+    }
+}

+ 144 - 0
ruoyi-modules/yp-talk/src/main/java/org/dromara/talk/service/impl/TtsServiceImpl.java

@@ -0,0 +1,144 @@
+package org.dromara.talk.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.talk.service.ITtsService;
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class TtsServiceImpl implements ITtsService {
+
+    @Value("${xfyun.tts.appid:}")
+    private String appId;
+
+    @Value("${xfyun.tts.api-key:}")
+    private String apiKey;
+
+    @Value("${xfyun.tts.api-secret:}")
+    private String apiSecret;
+
+    private static final String HOST_URL = "https://tts-api.xfyun.cn/v2/tts";
+
+    @Override
+    public void synthesize(String text, ITtsService.AudioCallback callback) {
+        try {
+            String wsUrl = getAuthUrl(HOST_URL, apiKey, apiSecret).replace("https://", "wss://");
+            URI uri = new URI(wsUrl);
+            String requestJson = buildRequest(text);
+
+            WebSocketClient client = new WebSocketClient(uri) {
+                @Override
+                public void onOpen(ServerHandshake handshake) {
+                    log.info("TTS WebSocket连接成功");
+                    send(requestJson);
+                }
+
+                @Override
+                public void onMessage(String message) {
+                    log.info("收到TTS响应: {}", message);
+                    TtsResponse response = JSON.parseObject(message, TtsResponse.class);
+
+                    if (response.code != 0) {
+                        log.error("TTS返回错误: code={}, message={}", response.code, response.message);
+                        callback.onError(response.code, response.message);
+                        return;
+                    }
+
+                    if (response.data != null && response.data.audio != null) {
+                        callback.onAudio(response.data.audio, response.data.status);
+                    }
+                }
+
+                @Override
+                public void onClose(int code, String reason, boolean remote) {
+                    log.info("TTS WebSocket连接关闭: code={}, reason={}, remote={}", code, reason, remote);
+                }
+
+                @Override
+                public void onError(Exception e) {
+                    log.error("TTS WebSocket错误", e);
+                    callback.onError(-1, e.getMessage());
+                }
+            };
+
+            client.connect();
+
+        } catch (Exception e) {
+            log.error("TTS合成失败", e);
+            callback.onError(-1, e.getMessage());
+        }
+    }
+
+    private String buildRequest(String text) {
+        return "{\n" +
+                "  \"common\": {\"app_id\": \"" + appId + "\"},\n" +
+                "  \"business\": {\n" +
+                "    \"aue\": \"lame\",\n" +
+                "    \"tte\": \"UTF8\",\n" +
+                "    \"ent\": \"intp65\",\n" +
+                "    \"vcn\": \"x4_yezi\",\n" +
+                "    \"speed\": 50,\n" +
+                "    \"pitch\": 50\n" +
+                "  },\n" +
+                "  \"data\": {\n" +
+                "    \"status\": 2,\n" +
+                "    \"text\": \"" + Base64.getEncoder()
+                .encodeToString(text.getBytes(StandardCharsets.UTF_8)) + "\"\n" +
+                "  }\n" +
+                "}";
+    }
+
+    private String getAuthUrl(String hostUrl, String apiKey, String apiSecret) throws Exception {
+        URL url = new URL(hostUrl);
+        SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
+        format.setTimeZone(TimeZone.getTimeZone("GMT"));
+        String date = format.format(new Date());
+
+        String preStr = "host: " + url.getHost() + "\n" +
+                "date: " + date + "\n" +
+                "GET " + url.getPath() + " HTTP/1.1";
+
+        Mac mac = Mac.getInstance("hmacsha256");
+        SecretKeySpec spec = new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "hmacsha256");
+        mac.init(spec);
+        byte[] hexDigits = mac.doFinal(preStr.getBytes(StandardCharsets.UTF_8));
+        String sha = Base64.getEncoder().encodeToString(hexDigits);
+
+        String authorization = String.format("api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"",
+                apiKey, "hmac-sha256", "host date request-line", sha);
+
+        String authBase64 = Base64.getEncoder().encodeToString(authorization.getBytes(StandardCharsets.UTF_8));
+
+        return "https://" + url.getHost() + url.getPath() +
+               "?authorization=" + authBase64 +
+               "&date=" + URLEncoder.encode(date, StandardCharsets.UTF_8) +
+               "&host=" + url.getHost();
+    }
+
+    static class TtsResponse {
+        public int code;
+        public String message;
+        public String sid;
+        public TtsData data;
+    }
+
+    static class TtsData {
+        public int status;
+        public String audio;
+        public String ced;
+    }
+}