diff --git a/黄永兴学习笔记/Spring AI Alibaba 智能客服+语音合成项目笔记.docx b/黄永兴学习笔记/Spring AI Alibaba 智能客服+语音合成项目笔记.docx new file mode 100644 index 0000000..10b6d00 Binary files /dev/null and b/黄永兴学习笔记/Spring AI Alibaba 智能客服+语音合成项目笔记.docx differ diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java b/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java deleted file mode 100644 index d42acb0..0000000 --- a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.example.springaialibaba.controller; - - -import com.alibaba.cloud.ai.dashscope.agent.DashScopeAgent; -import com.alibaba.cloud.ai.dashscope.agent.DashScopeAgentOptions; -import com.alibaba.dashscope.app.Application; -import com.alibaba.dashscope.app.ApplicationParam; -import com.alibaba.dashscope.app.ApplicationResult; -import com.alibaba.dashscope.app.FlowStreamMode; -import com.alibaba.dashscope.exception.InputRequiredException; -import com.alibaba.dashscope.exception.NoApiKeyException; -import io.reactivex.Flowable; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import reactor.adapter.rxjava.RxJava2Adapter; -import reactor.core.publisher.Flux; - -import java.nio.charset.StandardCharsets; - -@Slf4j -@RestController -@RequiredArgsConstructor //使用RequiredArgsConstructor注解,自动注入final类型的字段 -@RequestMapping("/customer") -public class CustomerServiceController { - - //注入Spring AI Alibaba Agent - //@Autowired - private final DashScopeAgent dashScopeAgent; - - /** - * 智能客服接口(非流式,同步获取结果) - * @param question 用户问题 - * @return 知识库标准答案 - */ - @GetMapping("/service") - public String customerService(@RequestParam("question") String question) { - - try { - /* - DashScopeAgentOptions 是工作流 / 智能体的调用参数,它只负责「触发工作流」,不负责「修改大模型的推理参数」。 - 因为 Spring AI Alibaba 的自动配置在工作流(Agent)模式下不生效! - */ - - // - /* ChatResponse response = dashScopeAgent.call( - new Prompt(question, DashScopeAgentOptions.builder() - .appId("df04e1abf24a416c8702260e88863ac4") - .build() - ) - ); - - return response.getResult().getOutput().getText();*/ - - ApplicationParam param = ApplicationParam.builder() - // 若没有配置环境变量,可用百炼API Key将下行替换为:.apiKey("sk-xxx")。但不建议在生产环境中直接将API Key硬编码到代码中,以减少API Key泄露风险。 - .apiKey(System.getenv("sk-f7d2253302b547c7a2fe257673cb854b")) - .appId("df04e1abf24a416c8702260e88863ac4") - .prompt(question) - .build(); - - Application application = new Application(); - ApplicationResult result = application.call(param); - return result.getOutput().getText(); - - } catch (Exception e) { - log.error("客服调用失败", e); - return "抱歉,智能客服暂时无法响应,请联系人工客服。"; - } - } - - // 官方原生流式调用(1:1对照文档) - @GetMapping(value = "/service2", produces = MediaType.TEXT_HTML_VALUE + ";charset=UTF-8") - public Flux customerService2( - @RequestParam("question") String question, - HttpServletResponse response - ) { - //设置字符编码为UTF-8,解决中文乱码问题 - response.setCharacterEncoding("UTF-8"); - - try { - // ============================================== - // 【官方原版写法】 - // ============================================== - ApplicationParam param = ApplicationParam.builder() - .apiKey("sk-f7d2253302b547c7a2fe257673cb854b") // 你的API Key - .appId("df04e1abf24a416c8702260e88863ac4") - .prompt(question) - .incrementalOutput(true) // 官方:流式必须开 - .flowStreamMode(FlowStreamMode.MESSAGE_FORMAT) // 设置为流模式 - .build(); - - Application application = new Application(); - - // 官方流式调用 - Flowable resultFlowable = application.streamCall(param); - - // 把 RxJava Flowable → Spring Flux(用于SSE输出) - // 正确的流式处理逻辑 - System.out.println(resultFlowable); - -/* return Flux.create(sink -> { - resultFlowable.subscribe( - res -> { - try { - if (res != null && res.getOutput() != null) { - String content = null; - - if ("stop".equals(res.getOutput().getFinishReason())) { - // 流结束 - content = res.getOutput().getText(); - if (content != null && !content.trim().isEmpty()) { - sink.next("data: " + content + "\n\n"); - } - } else { - // 流式输出 - if (res.getOutput().getWorkflowMessage() != null && - res.getOutput().getWorkflowMessage().getMessage() != null) { - - content = res.getOutput().getWorkflowMessage() - .getMessage().getContent(); - - if (content != null && !content.trim().isEmpty()) { - sink.next("data: " + content + "\n\n"); - } - } - } - } - } catch (Exception e) { - log.error("处理响应异常", e); - } - }, - error -> { - log.error("流式调用异常", error); - sink.next("data: 服务调用异常\n\n"); - sink.complete(); - }, - () -> { - log.info("流式调用完成"); - sink.complete(); - } - ); - });*/ - - // RxJava适配器 flowable 转换为 flux - return RxJava2Adapter.flowableToFlux(resultFlowable) - .map(this::extractContent) - .filter(content -> content != null && !content.trim().isEmpty()) - .map(content -> "data: " + content + "\n\n"); - - - } catch (Exception e) { - log.error("客服调用失败", e); - return Flux.error(e); - } - } - - // 从 ApplicationResult 中提取内容 - private String extractContent(ApplicationResult res) { - if (res == null || res.getOutput() == null) return null; - - if ("stop".equals(res.getOutput().getFinishReason())) { - return res.getOutput().getText(); - } else if (res.getOutput().getWorkflowMessage() != null && - res.getOutput().getWorkflowMessage().getMessage() != null) { - return res.getOutput().getWorkflowMessage().getMessage().getContent(); - } - return null; - } -} \ No newline at end of file diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class b/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class deleted file mode 100644 index a7758aa..0000000 Binary files a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class and /dev/null differ diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class b/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class deleted file mode 100644 index 94e874d..0000000 Binary files a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class and /dev/null differ diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/.mvn/wrapper/maven-wrapper.properties b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/.mvn/wrapper/maven-wrapper.properties similarity index 100% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/.mvn/wrapper/maven-wrapper.properties rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/.mvn/wrapper/maven-wrapper.properties diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/SpringAIAlibaba.iml b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/SpringAIAlibaba.iml similarity index 100% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/SpringAIAlibaba.iml rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/SpringAIAlibaba.iml diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/pom.xml b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/pom.xml similarity index 94% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/pom.xml rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/pom.xml index 2f3e575..11bce27 100644 --- a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/pom.xml +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/pom.xml @@ -84,10 +84,15 @@ spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + io.projectreactor.addons reactor-adapter - 3.5.0 @@ -113,6 +118,7 @@ + diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/java/com/example/springaialibaba/SpringAiAlibabaApplication.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/SpringAiAlibabaApplication.java similarity index 100% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/java/com/example/springaialibaba/SpringAiAlibabaApplication.java rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/SpringAiAlibabaApplication.java diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java new file mode 100644 index 0000000..db5a87f --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java @@ -0,0 +1,230 @@ +package com.example.springaialibaba.controller; + + +import com.alibaba.cloud.ai.dashscope.agent.DashScopeAgent; +import com.alibaba.dashscope.app.*; +import com.example.springaialibaba.pojo.StreamResponse; +import com.example.springaialibaba.service.SpeechSynthesisService; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.reactivex.Flowable; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.nio.ByteBuffer; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +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.StreamingResponseBody; +import reactor.adapter.rxjava.RxJava2Adapter; +import reactor.adapter.rxjava.RxJava3Adapter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Slf4j +@RestController +@RequiredArgsConstructor //使用RequiredArgsConstructor注解,自动注入final类型的字段 +@RequestMapping("/customer") +public class CustomerServiceController { + + //注入Spring AI Alibaba Agent + //@Autowired + private final DashScopeAgent dashScopeAgent; + private final SpeechSynthesisService speechSynthesisService; + + /** + * 智能客服接口(非流式,同步获取结果) + * @param question 用户问题 + * @return 知识库标准答案 + */ + @GetMapping("/service") + public String customerServiceController(@RequestParam("question") String question) { + + try { + /* + DashScopeAgentOptions 是工作流 / 智能体的调用参数,它只负责「触发工作流」,不负责「修改大模型的推理参数」。 + 因为 Spring AI Alibaba 的自动配置在工作流(Agent)模式下不生效! + */ + + // + /* ChatResponse response = dashScopeAgent.call( + new Prompt(question, DashScopeAgentOptions.builder() + .appId("df04e1abf24a416c8702260e88863ac4") + .build() + ) + ); + + return response.getResult().getOutput().getText();*/ + + ApplicationParam param = ApplicationParam.builder() + // 若没有配置环境变量,可用百炼API Key将下行替换为:.apiKey("sk-xxx")。但不建议在生产环境中直接将API Key硬编码到代码中,以减少API Key泄露风险。 + .apiKey(System.getenv("sk-f7d2253302b547c7a2fe257673cb854b")) + .appId("df04e1abf24a416c8702260e88863ac4") + .prompt(question) + .build(); + + Application application = new Application(); + ApplicationResult result = application.call(param); + return result.getOutput().getText(); + + } catch (Exception e) { + log.error("客服调用失败", e); + return "抱歉,智能客服暂时无法响应,请联系人工客服。"; + } + } + + // 官方原生流式调用(1:1对照文档) + @GetMapping(value = "/service2", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux customerService2Controller( + @RequestParam("question") String question, + HttpServletResponse response + ) { + //设置字符编码为UTF-8,解决中文乱码问题 + response.setCharacterEncoding("UTF-8"); + + ObjectMapper objectMapper = new ObjectMapper(); + AtomicInteger counter = new AtomicInteger(0); + + try { + // 【官方原版写法】 + ApplicationParam param = ApplicationParam.builder() + .apiKey("sk-f7d2253302b547c7a2fe257673cb854b") + .appId("df04e1abf24a416c8702260e88863ac4") + .prompt(question) + .flowStreamMode(FlowStreamMode.MESSAGE_FORMAT) + .build(); + + Application application = new Application(); + + // 官方流式调用 + Flowable resultFlowable = application.streamCall(param); + + // RxJava适配器 flowable 转换为 flux Mono是单元素的Flux + return RxJava2Adapter.flowableToFlux(resultFlowable) + .flatMap(res -> { + String content = extractContent(res); + if (content != null && !content.trim().isEmpty()) { + try { + String id = UUID.randomUUID().toString().substring(0, 8); + int seq = counter.incrementAndGet(); + String jsonData = objectMapper.writeValueAsString(new StreamResponse(id, seq, content)); + return Mono.just(jsonData); + } catch (Exception e) { + log.error("JSON序列化失败", e); + return Mono.empty(); + } + } + return Mono.empty(); + }); + + } catch (Exception e) { + log.error("客服调用失败", e); + return Flux.error(e); + } + } + + + // 语音合成接口 - 单向流式返回音频流数据 + @GetMapping(value = "/synthesize", produces = "audio/wav") + public StreamingResponseBody synthesizeSpeech(@RequestParam("text") String text) { + log.info("开始语音合成,文本: {}", text); + + return outputStream -> { + CountDownLatch latch = new CountDownLatch(1); + try { + Flux audioFlux = speechSynthesisService.streamSynthesize(text); + audioFlux.subscribe( + bytes -> { + try { + log.info("Controller收到音频数据,大小: {} 字节,写入输出流", bytes.length); + outputStream.write(bytes); + outputStream.flush(); + log.info("已刷新输出流"); + } catch (Exception e) { + log.error("写入音频数据失败", e); + } + }, + error -> { + log.error("音频流处理错误", error); + latch.countDown(); + }, + () -> { + log.info("音频数据传输完成"); + latch.countDown(); + } + ); + log.info("等待音频流完成..."); + latch.await(); + log.info("音频流完成,结束请求"); + } catch (Exception e) { + log.error("语音合成失败", e); + } + }; + } + + // SSE推送音频数据接口 + @GetMapping(value = "/synthesize-sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux synthesizeSpeechSSE(@RequestParam("text") String text) { + log.info("开始SSE语音合成,文本: {}", text); + + Flux audioFlux = speechSynthesisService.streamSynthesize(text); + AtomicInteger seq = new AtomicInteger(0); + + return audioFlux + .map(bytes -> { + String base64Data = Base64.getEncoder().encodeToString(bytes); + int currentSeq = seq.incrementAndGet(); + String jsonData = String.format("{\"seq\":%d,\"data\":\"%s\",\"size\":%d}", + currentSeq, base64Data, bytes.length); + log.info("SSE推送音频数据块 [{}], 大小: {} 字节", currentSeq, bytes.length); + return "data: " + jsonData + "\n\n"; + }) + .doOnComplete(() -> { + log.info("SSE音频推送完成"); + }) + .doOnError(error -> { + log.error("SSE音频推送错误", error); + }); + } + + // 从 ApplicationResult 中提取内容 + private String extractContent(ApplicationResult res) { + if (res == null || res.getOutput() == null) return null; + if (res.getOutput().getWorkflowMessage() != null) { + WorkflowMessage workflowMessage = res.getOutput().getWorkflowMessage(); + if (workflowMessage != null && workflowMessage.getMessage() != null) { + String nodeId = workflowMessage.getNodeId(); + String content = workflowMessage.getMessage().getContent(); + // 区分不同的nodeId + if ("Output_emZG".equals(nodeId)) { + // 流程输出节点,返回内容 + return content; + } else if ("End_mOtD".equals(nodeId)) { + // 结束节点,不返回内容 + log.debug("流式传输结束,收到完整结果,不返回"); + return null; + } else { + // 其他节点,返回内容 + return content; + } + } + } + return null; + } + + + + + + + + + +} \ No newline at end of file diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/Main.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/Main.java new file mode 100644 index 0000000..a2764a4 --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/Main.java @@ -0,0 +1,97 @@ +package com.example.springaialibaba.controller; + +import com.alibaba.dashscope.audio.tts.SpeechSynthesisResult; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesisAudioFormat; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesisParam; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesizer; +import com.alibaba.dashscope.common.ResultCallback; +import com.alibaba.dashscope.utils.Constants; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +class TimeUtils { + private static final DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + public static String getTimestamp() { + return LocalDateTime.now().format(formatter); + } +} + + +public class Main { + private static String[] textArray = {"流式文本语音合成SDK,", + "可以将输入的文本", "合成为语音二进制数据,", "相比于非流式语音合成,", + "流式合成的优势在于实时性", "更强。用户在输入文本的同时", + "可以听到接近同步的语音输出,", "极大地提升了交互体验,", + "减少了用户等待时间。", "适用于调用大规模", "语言模型(LLM),以", + "流式输入文本的方式", "进行语音合成的场景。"}; + private static String model = "cosyvoice-v3-flash"; // 模型 + private static String voice = "longanyang"; // 音色 + + public static void streamAudioDataToSpeaker() { + // 配置回调函数 + ResultCallback callback = new ResultCallback() { + @Override + public void onEvent(SpeechSynthesisResult result) { + // System.out.println("收到消息: " + result); + if (result.getAudioFrame() != null) { + // 此处实现处理音频数据的逻辑 + System.out.println(TimeUtils.getTimestamp() + " 收到音频"); + } + } + + @Override + public void onComplete() { + System.out.println(TimeUtils.getTimestamp() + " 收到Complete,语音合成结束"); + } + + @Override + public void onError(Exception e) { + System.out.println("出现异常:" + e.toString()); + } + }; + + // 请求参数 + SpeechSynthesisParam param = + SpeechSynthesisParam.builder() + // 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key + // 若没有配置环境变量,请用百炼API Key将下行替换为:.apiKey("sk-xxx") + .apiKey("sk-f7d2253302b547c7a2fe257673cb854b") + .model(model) + .voice(voice) + .format(SpeechSynthesisAudioFormat + .PCM_22050HZ_MONO_16BIT) // 流式合成使用PCM或者MP3 + .build(); + SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, callback); + // 带Callback的call方法将不会阻塞当前线程 + try { + for (String text : textArray) { + // 发送文本片段,在回调接口的onEvent方法中实时获取二进制音频 + synthesizer.streamingCall(text); + } + // 等待结束流式语音合成 + synthesizer.streamingComplete(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + // 任务结束关闭websocket连接 + synthesizer.getDuplexApi().close(1000, "bye"); + } + + // 首次发送文本时需建立 WebSocket 连接,因此首包延迟会包含连接建立的耗时 + System.out.println( + "[Metric] requestId为:" + + synthesizer.getLastRequestId() + + ",首包延迟(毫秒)为:" + + synthesizer.getFirstPackageDelay()); + } + + public static void main(String[] args) { + // 以下为北京地域url,若使用新加坡地域的模型,需将url替换为:wss://dashscope-intl.aliyuncs.com/api-ws/v1/inference + Constants.baseWebsocketApiUrl = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"; + streamAudioDataToSpeaker(); + System.exit(0); + } +} \ No newline at end of file diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/SpeechSynthesisController.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/SpeechSynthesisController.java new file mode 100644 index 0000000..287df03 --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/SpeechSynthesisController.java @@ -0,0 +1,118 @@ +package com.example.springaialibaba.controller; + +import com.alibaba.dashscope.audio.tts.SpeechSynthesisParam; +import com.alibaba.dashscope.audio.tts.SpeechSynthesizer; +import com.alibaba.dashscope.audio.tts.SpeechSynthesisResult; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.utils.Constants; +import io.reactivex.Flowable; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.adapter.rxjava.RxJava2Adapter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Base64; + +@Slf4j +@RestController +@RequestMapping("/speech") +public class SpeechSynthesisController { + + private static final String MODEL = "cosyvoice-v3-flash"; + private static final String VOICE = "longanyang"; + + static { + // 北京地域url,若使用新加坡地域的模型,需将url替换为:wss://dashscope-intl.aliyuncs.com/api-ws/v1/inference + Constants.baseWebsocketApiUrl = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"; + } + + @GetMapping(value = "/synthesize", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public void synthesizeAudio( + @RequestParam("text") String text, + HttpServletResponse response + ) throws NoApiKeyException, IOException { + + // 设置响应头 + response.setContentType("audio/wav"); + response.setHeader("Content-Disposition", "attachment; filename=output.wav"); + + // 请求参数 + SpeechSynthesisParam param = SpeechSynthesisParam.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .model(MODEL) + .voice(VOICE) + .build(); + + SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, null); + + // 流式获取音频数据 + Flowable resultFlowable = synthesizer.callAsFlowable(text); + + // 处理音频数据 + OutputStream outputStream = response.getOutputStream(); + + resultFlowable.blockingForEach(result -> { + if (result.getAudioFrame() != null) { + try { + // 音频数据是Base64编码的,需要解码 + byte[] audioData = Base64.getDecoder().decode(result.getAudioFrame().getAudio()); + outputStream.write(audioData); + outputStream.flush(); + log.debug("写入音频数据: {} bytes", audioData.length); + } catch (IOException e) { + log.error("写入音频数据失败", e); + } + } + }); + + // 关闭连接和输出流 + synthesizer.getDuplexApi().close(1000, "bye"); + outputStream.close(); + + // 打印首包延迟 + log.info("[Metric] requestId为:{}", synthesizer.getLastRequestId()); + log.info("首包延迟(毫秒)为:{}", synthesizer.getFirstPackageDelay()); + } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamAudio( + @RequestParam("text") String text + ) throws NoApiKeyException { + + // 请求参数 + SpeechSynthesisParam param = SpeechSynthesisParam.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .model(MODEL) + .voice(VOICE) + .build(); + + SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, null); + + // 流式获取音频数据 + Flowable resultFlowable = synthesizer.callAsFlowable(text); + + // 转换为Flux并返回 + return RxJava2Adapter.flowableToFlux(resultFlowable) + .flatMap(result -> { + if (result.getAudioFrame() != null) { + String audioBase64 = result.getAudioFrame().getAudio(); + return Mono.just("data: " + audioBase64 + "\n\n"); + } + return Mono.empty(); + }) + .doOnTerminate(() -> { + // 关闭连接 + synthesizer.getDuplexApi().close(1000, "bye"); + log.info("[Metric] requestId为:{}", synthesizer.getLastRequestId()); + log.info("首包延迟(毫秒)为:{}", synthesizer.getFirstPackageDelay()); + }); + } +} \ No newline at end of file diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/pojo/StreamResponse.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/pojo/StreamResponse.java new file mode 100644 index 0000000..ed3b507 --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/pojo/StreamResponse.java @@ -0,0 +1,14 @@ +package com.example.springaialibaba.pojo; + +// 流式响应数据结构 +public class StreamResponse { + public String id; + public int seq; + public String text; + + public StreamResponse(String id, int seq, String text) { + this.id = id; + this.seq = seq; + this.text = text; + } +} \ No newline at end of file diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/AudioGenerationService.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/AudioGenerationService.java new file mode 100644 index 0000000..086f7ac --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/AudioGenerationService.java @@ -0,0 +1,107 @@ +package com.example.springaialibaba.service; + +import com.alibaba.dashscope.audio.tts.SpeechSynthesisResult; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesisParam; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesizer; +import com.alibaba.dashscope.common.ResultCallback; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.utils.Constants; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class AudioGenerationService { + + private static final String MODEL = "cosyvoice-v3-flash"; + private static final String VOICE = "longanyang"; + + static { + // 设置WebSocket API地址(北京地域) + Constants.baseWebsocketApiUrl = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"; + } + + /** + * 生成文本的音频数据 + * @param text 要转换为音频的文本 + * @return 音频数据的Flux流 + */ + public Flux generateAudio(String text) { + return Mono.fromCallable(() -> { + try { + SpeechSynthesisParam param = SpeechSynthesisParam.builder() + .apiKey("sk-f7d2253302b547c7a2fe257673cb854b") + .model(MODEL) + .voice(VOICE) + .build(); + + SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, null); + + List audioChunks = new ArrayList<>(); + + synthesizer.callAsFlowable(text).blockingForEach(result -> { + if (result.getAudioFrame() != null) { + ByteBuffer audioData = ByteBuffer.wrap(result.getAudioFrame()); + audioChunks.add(audioData); + log.debug("收到音频数据块,大小: {} bytes", result.getAudioFrame().length); + } + }); + + synthesizer.getDuplexApi().close(1000, "bye"); + + log.info("音频生成完成,首包延迟: {} ms", synthesizer.getFirstPackageDelay()); + + return Flux.fromIterable(audioChunks); + + } catch (NoApiKeyException e) { + log.error("API Key错误", e); + return Flux.error(e); + } catch (Exception e) { + log.error("音频生成失败", e); + return Flux.error(e); + } + }).flatMapMany(flux -> flux); + } + + /** + * 生成单个音频数据块(用于实时流式传输) + * @param text 要转换为音频的文本 + * @return 音频数据的Mono + */ + public Mono generateAudioChunk(String text) { + return Mono.fromCallable(() -> { + try { + SpeechSynthesisParam param = SpeechSynthesisParam.builder() + .apiKey("sk-f7d2253302b547c7a2fe257673cb854b") + .model(MODEL) + .voice(VOICE) + .build(); + + SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, null); + + ByteBuffer audioData = null; + + synthesizer.callAsFlowable(text).blockingForEach(result -> { + if (result.getAudioFrame() != null) { + audioData = ByteBuffer.wrap(result.getAudioFrame()); + log.debug("生成音频数据块,大小: {} bytes", result.getAudioFrame().length); + } + }); + + synthesizer.getDuplexApi().close(1000, "bye"); + + return audioData; + + } catch (Exception e) { + log.error("音频生成失败", e); + throw new RuntimeException("音频生成失败", e); + } + }); + } +} \ No newline at end of file diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/SpeechSynthesisService.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/SpeechSynthesisService.java new file mode 100644 index 0000000..2bd95cd --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/SpeechSynthesisService.java @@ -0,0 +1,82 @@ +package com.example.springaialibaba.service; + + +import com.alibaba.dashscope.audio.tts.SpeechSynthesisResult; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesisParam; +import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesizer; +import com.alibaba.dashscope.utils.Constants; +import io.reactivex.Flowable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.adapter.rxjava.RxJava2Adapter; +import reactor.core.publisher.Flux; + +import java.nio.ByteBuffer; + +@Slf4j +@Service +public class SpeechSynthesisService { + + private static final String MODEL = "cosyvoice-v3-flash"; + private static final String VOICE = "longanyang"; + + public Flux streamSynthesize(String text) { + try { + Constants.baseWebsocketApiUrl = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"; + + SpeechSynthesisParam param = SpeechSynthesisParam.builder() + .apiKey("sk-f7d2253302b547c7a2fe257673cb854b") + .model(MODEL) + .voice(VOICE) + .build(); + + SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, null); + Flowable resultFlowable = synthesizer.callAsFlowable(text); + + Flux audioFlux = RxJava2Adapter.flowableToFlux(resultFlowable) + .doOnNext(result -> { + log.info("收到SpeechSynthesisResult, audioFrame: {}, frameSize: {}", + result.getAudioFrame() != null, + result.getAudioFrame() != null ? result.getAudioFrame().remaining() : 0); + }) + .filter(result -> { + boolean hasAudio = result.getAudioFrame() != null; + if (!hasAudio) { + log.info("跳过无音频帧的结果"); + } + return hasAudio; + }) + .map(result -> { + ByteBuffer buffer = result.getAudioFrame(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + log.info("处理音频数据块,大小: {} 字节", bytes.length); + return bytes; + }) + .doOnComplete(() -> { + log.info("语音合成完成,requestId: {}, 首包延迟: {}ms", + synthesizer.getLastRequestId(), + synthesizer.getFirstPackageDelay()); + try { + synthesizer.getDuplexApi().close(1000, "bye"); + } catch (Exception e) { + log.error("关闭连接失败", e); + } + }) + .doOnError(error -> { + log.error("语音合成错误", error); + try { + synthesizer.getDuplexApi().close(1000, "bye"); + } catch (Exception e) { + log.error("关闭连接失败", e); + } + }); + + return audioFlux; + + } catch (Exception e) { + log.error("语音合成失败", e); + return Flux.error(e); + } + } +} diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/resources/application.yml b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/application.yml similarity index 99% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/resources/application.yml rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/application.yml index fecb5d1..d778e3b 100644 --- a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/resources/application.yml +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/application.yml @@ -11,3 +11,4 @@ spring: api-key: sk-f7d2253302b547c7a2fe257673cb854b # 业务空间ID workspace-id: llm-fuczq7vkh8vyz716 + diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/static/index.html b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/static/index.html new file mode 100644 index 0000000..49aa2f0 --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/static/index.html @@ -0,0 +1,263 @@ + + + + + + 智能客服 - 文本 + 语音 + + + +
+

🤖 智能客服 - 文本 + 语音

+ +
+ + +
+ +
+ + +
+ +
+ 状态:等待提问 +
+ +
+
📝 回答内容
+
+ (回答将在这里显示) +
+
+ + +
+ + + + diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/test/java/com/example/springaialibaba/SpringAiAlibabaApplicationTests.java b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/test/java/com/example/springaialibaba/SpringAiAlibabaApplicationTests.java similarity index 100% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/test/java/com/example/springaialibaba/SpringAiAlibabaApplicationTests.java rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/test/java/com/example/springaialibaba/SpringAiAlibabaApplicationTests.java diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/application.yml b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/application.yml similarity index 99% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/application.yml rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/application.yml index fecb5d1..d778e3b 100644 --- a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/application.yml +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/application.yml @@ -11,3 +11,4 @@ spring: api-key: sk-f7d2253302b547c7a2fe257673cb854b # 业务空间ID workspace-id: llm-fuczq7vkh8vyz716 + diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class new file mode 100644 index 0000000..8c0b7bb Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class new file mode 100644 index 0000000..0162ced Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main$1.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main$1.class new file mode 100644 index 0000000..6c4c9f9 Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main$1.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main.class new file mode 100644 index 0000000..673fad0 Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/TimeUtils.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/TimeUtils.class new file mode 100644 index 0000000..0d5a585 Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/TimeUtils.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/pojo/StreamResponse.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/pojo/StreamResponse.class new file mode 100644 index 0000000..93cfc5c Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/pojo/StreamResponse.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/service/SpeechSynthesisService.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/service/SpeechSynthesisService.class new file mode 100644 index 0000000..6a49745 Binary files /dev/null and b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/service/SpeechSynthesisService.class differ diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/static/index.html b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/static/index.html new file mode 100644 index 0000000..49aa2f0 --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/static/index.html @@ -0,0 +1,263 @@ + + + + + + 智能客服 - 文本 + 语音 + + + +
+

🤖 智能客服 - 文本 + 语音

+ +
+ + +
+ +
+ + +
+ +
+ 状态:等待提问 +
+ +
+
📝 回答内容
+
+ (回答将在这里显示) +
+
+ + +
+ + + + diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..e69de29 diff --git a/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..b4d318e --- /dev/null +++ b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,4 @@ +C:\Users\Mayn\IdeaProjects\StudySpringAI\StudySpringAI\SpringAIAlibaba\src\main\java\com\example\springaialibaba\controller\CustomerServiceController.java +C:\Users\Mayn\IdeaProjects\StudySpringAI\StudySpringAI\SpringAIAlibaba\src\main\java\com\example\springaialibaba\controller\Main.java +C:\Users\Mayn\IdeaProjects\StudySpringAI\StudySpringAI\SpringAIAlibaba\src\main\java\com\example\springaialibaba\service\SpeechSynthesisService.java +C:\Users\Mayn\IdeaProjects\StudySpringAI\StudySpringAI\SpringAIAlibaba\src\main\java\com\example\springaialibaba\SpringAiAlibabaApplication.java diff --git a/黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/test-classes/com/example/springaialibaba/SpringAiAlibabaApplicationTests.class b/黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/test-classes/com/example/springaialibaba/SpringAiAlibabaApplicationTests.class similarity index 100% rename from 黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/test-classes/com/example/springaialibaba/SpringAiAlibabaApplicationTests.class rename to 黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/test-classes/com/example/springaialibaba/SpringAiAlibabaApplicationTests.class