Browse Source
Merge branch 'huangyongxing/feature-20260327161252-学习笔记' into milestone-20260325-学习笔记
milestone-20260325-学习笔记
Merge branch 'huangyongxing/feature-20260327161252-学习笔记' into milestone-20260325-学习笔记
milestone-20260325-学习笔记
29 changed files with 1187 additions and 178 deletions
-
BIN黄永兴学习笔记/Spring AI Alibaba 智能客服+语音合成项目笔记.docx
-
177黄永兴学习笔记/智能客服demo/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java
-
BIN黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class
-
BIN黄永兴学习笔记/智能客服demo/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class
-
0黄永兴学习笔记/智能客服案例/SpringAIAlibaba/.mvn/wrapper/maven-wrapper.properties
-
0黄永兴学习笔记/智能客服案例/SpringAIAlibaba/SpringAIAlibaba.iml
-
8黄永兴学习笔记/智能客服案例/SpringAIAlibaba/pom.xml
-
0黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/SpringAiAlibabaApplication.java
-
230黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/CustomerServiceController.java
-
97黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/Main.java
-
118黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/controller/SpeechSynthesisController.java
-
14黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/pojo/StreamResponse.java
-
107黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/AudioGenerationService.java
-
82黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/java/com/example/springaialibaba/service/SpeechSynthesisService.java
-
1黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/application.yml
-
263黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/main/resources/static/index.html
-
0黄永兴学习笔记/智能客服案例/SpringAIAlibaba/src/test/java/com/example/springaialibaba/SpringAiAlibabaApplicationTests.java
-
1黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/application.yml
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/SpringAiAlibabaApplication.class
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/CustomerServiceController.class
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main$1.class
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/Main.class
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/controller/TimeUtils.class
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/pojo/StreamResponse.class
-
BIN黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/com/example/springaialibaba/service/SpeechSynthesisService.class
-
263黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/classes/static/index.html
-
0黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
-
4黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
-
0黄永兴学习笔记/智能客服案例/SpringAIAlibaba/target/test-classes/com/example/springaialibaba/SpringAiAlibabaApplicationTests.class
@ -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<String> 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<ApplicationResult> 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; |
|
||||
} |
|
||||
} |
|
||||
@ -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<String> 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<ApplicationResult> 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<byte[]> 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<String> synthesizeSpeechSSE(@RequestParam("text") String text) { |
||||
|
log.info("开始SSE语音合成,文本: {}", text); |
||||
|
|
||||
|
Flux<byte[]> 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; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
} |
||||
@ -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<SpeechSynthesisResult> callback = new ResultCallback<SpeechSynthesisResult>() { |
||||
|
@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); |
||||
|
} |
||||
|
} |
||||
@ -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<SpeechSynthesisResult> 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<String> 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<SpeechSynthesisResult> 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()); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<ByteBuffer> 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<ByteBuffer> 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<ByteBuffer> 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); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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<byte[]> 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<SpeechSynthesisResult> resultFlowable = synthesizer.callAsFlowable(text); |
||||
|
|
||||
|
Flux<byte[]> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,263 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>智能客服 - 文本 + 语音</title> |
||||
|
<style> |
||||
|
body { |
||||
|
font-family: Arial, sans-serif; |
||||
|
max-width: 1000px; |
||||
|
margin: 30px auto; |
||||
|
padding: 20px; |
||||
|
} |
||||
|
.container { |
||||
|
border: 1px solid #ddd; |
||||
|
border-radius: 8px; |
||||
|
padding: 30px; |
||||
|
} |
||||
|
h1 { |
||||
|
color: #333; |
||||
|
margin-bottom: 30px; |
||||
|
} |
||||
|
.input-group { |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
textarea { |
||||
|
width: 100%; |
||||
|
height: 100px; |
||||
|
padding: 12px; |
||||
|
font-size: 16px; |
||||
|
border: 1px solid #ccc; |
||||
|
border-radius: 4px; |
||||
|
resize: vertical; |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
button { |
||||
|
padding: 12px 30px; |
||||
|
font-size: 16px; |
||||
|
background-color: #007bff; |
||||
|
color: white; |
||||
|
border: none; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
button:hover { |
||||
|
background-color: #0056b3; |
||||
|
} |
||||
|
button:disabled { |
||||
|
background-color: #ccc; |
||||
|
cursor: not-allowed; |
||||
|
} |
||||
|
.output-section { |
||||
|
margin-top: 30px; |
||||
|
border-top: 1px solid #eee; |
||||
|
padding-top: 20px; |
||||
|
} |
||||
|
.output-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: bold; |
||||
|
margin-bottom: 15px; |
||||
|
color: #333; |
||||
|
} |
||||
|
.output-text { |
||||
|
padding: 15px; |
||||
|
background-color: #f8f9fa; |
||||
|
border-radius: 4px; |
||||
|
border: 1px solid #e9ecef; |
||||
|
min-height: 100px; |
||||
|
font-size: 16px; |
||||
|
line-height: 1.8; |
||||
|
color: #333; |
||||
|
white-space: pre-wrap; |
||||
|
} |
||||
|
.status { |
||||
|
margin-top: 20px; |
||||
|
padding: 15px; |
||||
|
background-color: #d1ecf1; |
||||
|
border-radius: 4px; |
||||
|
color: #0c5460; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
audio { |
||||
|
margin-top: 20px; |
||||
|
width: 100%; |
||||
|
} |
||||
|
.example-btn { |
||||
|
margin-left: 10px; |
||||
|
background-color: #28a745; |
||||
|
} |
||||
|
.example-btn:hover { |
||||
|
background-color: #218838; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="container"> |
||||
|
<h1>🤖 智能客服 - 文本 + 语音</h1> |
||||
|
|
||||
|
<div class="input-group"> |
||||
|
<label style="display: block; margin-bottom: 10px; font-size: 16px;">请输入您的问题:</label> |
||||
|
<textarea id="questionInput" placeholder="请输入您的问题,例如:什么是半仓?"></textarea> |
||||
|
</div> |
||||
|
|
||||
|
<div class="input-group"> |
||||
|
<button id="generateBtn" onclick="askQuestion()">提问</button> |
||||
|
<button class="example-btn" onclick="fillExample()">示例问题</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="status" id="status"> |
||||
|
状态:等待提问 |
||||
|
</div> |
||||
|
|
||||
|
<div class="output-section"> |
||||
|
<div class="output-title">📝 回答内容</div> |
||||
|
<div class="output-text" id="outputText"> |
||||
|
(回答将在这里显示) |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<audio id="audioPlayer" controls></audio> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
const exampleQuestion = "什么是半仓?"; |
||||
|
|
||||
|
function fillExample() { |
||||
|
document.getElementById('questionInput').value = exampleQuestion; |
||||
|
} |
||||
|
|
||||
|
async function askQuestion() { |
||||
|
const question = document.getElementById('questionInput').value.trim(); |
||||
|
const statusDiv = document.getElementById('status'); |
||||
|
const outputText = document.getElementById('outputText'); |
||||
|
const audioPlayer = document.getElementById('audioPlayer'); |
||||
|
const generateBtn = document.getElementById('generateBtn'); |
||||
|
|
||||
|
if (!question) { |
||||
|
alert('请输入您的问题!'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
generateBtn.disabled = true; |
||||
|
outputText.textContent = ''; |
||||
|
statusDiv.textContent = '状态:正在获取回答...'; |
||||
|
console.log('开始提问:', question); |
||||
|
|
||||
|
let fullText = ''; |
||||
|
let eventSource = null; |
||||
|
|
||||
|
try { |
||||
|
eventSource = new EventSource(`/customer/service2?question=${encodeURIComponent(question)}`); |
||||
|
|
||||
|
eventSource.onmessage = (event) => { |
||||
|
console.log('收到原始数据:', event.data); |
||||
|
|
||||
|
const dataStr = event.data; |
||||
|
|
||||
|
if (!dataStr || dataStr.trim() === '') { |
||||
|
console.log('跳过空数据'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const data = JSON.parse(dataStr); |
||||
|
const text = data.text; |
||||
|
if (text) { |
||||
|
fullText += text; |
||||
|
outputText.textContent += text; |
||||
|
outputText.scrollTop = outputText.scrollHeight; |
||||
|
statusDiv.textContent = `状态:正在获取回答... (已收到 ${fullText.length} 字)`; |
||||
|
console.log('解析到文本:', text); |
||||
|
} |
||||
|
} catch (parseError) { |
||||
|
console.error('JSON解析失败:', parseError, '原始数据:', dataStr); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
eventSource.onerror = (error) => { |
||||
|
console.log('EventSource 连接关闭:', error); |
||||
|
eventSource.close(); |
||||
|
|
||||
|
if (fullText.length > 0) { |
||||
|
statusDiv.textContent = '状态:回答获取完成,正在生成语音...'; |
||||
|
console.log('完整回答:', fullText); |
||||
|
|
||||
|
generateAndPlayAudio(fullText); |
||||
|
} else { |
||||
|
statusDiv.textContent = '状态:未获取到回答'; |
||||
|
generateBtn.disabled = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('获取回答错误:', error); |
||||
|
statusDiv.textContent = `状态:获取回答失败 - ${error.message}`; |
||||
|
generateBtn.disabled = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function generateAndPlayAudio(text) { |
||||
|
const statusDiv = document.getElementById('status'); |
||||
|
const audioPlayer = document.getElementById('audioPlayer'); |
||||
|
const generateBtn = document.getElementById('generateBtn'); |
||||
|
|
||||
|
statusDiv.textContent = '状态:正在生成语音...'; |
||||
|
console.log('开始语音合成,文本长度:', text.length); |
||||
|
|
||||
|
try { |
||||
|
const url = `/customer/synthesize?text=${encodeURIComponent(text)}`; |
||||
|
|
||||
|
audioPlayer.src = url; |
||||
|
|
||||
|
audioPlayer.onloadstart = () => { |
||||
|
statusDiv.textContent = '状态:开始接收音频流...'; |
||||
|
console.log('开始接收音频流'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onloadeddata = () => { |
||||
|
statusDiv.textContent = '状态:数据加载中,准备播放...'; |
||||
|
console.log('音频数据加载完成,可以开始播放'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onplaying = () => { |
||||
|
statusDiv.textContent = '状态:正在播放(流式)'; |
||||
|
console.log('开始播放音频'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onwaiting = () => { |
||||
|
statusDiv.textContent = '状态:缓冲中...'; |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.oncanplay = () => { |
||||
|
statusDiv.textContent = '状态:正在播放(流畅)'; |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onended = () => { |
||||
|
statusDiv.textContent = '状态:播放完成'; |
||||
|
generateBtn.disabled = false; |
||||
|
console.log('播放完成'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onerror = (e) => { |
||||
|
console.error('音频播放错误:', e); |
||||
|
statusDiv.textContent = `状态:音频播放失败 - ${audioPlayer.error ? audioPlayer.error.message : '未知错误'}`; |
||||
|
generateBtn.disabled = false; |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.play(); |
||||
|
console.log('已调用 audio.play()'); |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('语音合成错误:', error); |
||||
|
statusDiv.textContent = `状态:出错了 - ${error.message}`; |
||||
|
generateBtn.disabled = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
window.onload = () => { |
||||
|
console.log('页面加载完成'); |
||||
|
}; |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,263 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>智能客服 - 文本 + 语音</title> |
||||
|
<style> |
||||
|
body { |
||||
|
font-family: Arial, sans-serif; |
||||
|
max-width: 1000px; |
||||
|
margin: 30px auto; |
||||
|
padding: 20px; |
||||
|
} |
||||
|
.container { |
||||
|
border: 1px solid #ddd; |
||||
|
border-radius: 8px; |
||||
|
padding: 30px; |
||||
|
} |
||||
|
h1 { |
||||
|
color: #333; |
||||
|
margin-bottom: 30px; |
||||
|
} |
||||
|
.input-group { |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
textarea { |
||||
|
width: 100%; |
||||
|
height: 100px; |
||||
|
padding: 12px; |
||||
|
font-size: 16px; |
||||
|
border: 1px solid #ccc; |
||||
|
border-radius: 4px; |
||||
|
resize: vertical; |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
button { |
||||
|
padding: 12px 30px; |
||||
|
font-size: 16px; |
||||
|
background-color: #007bff; |
||||
|
color: white; |
||||
|
border: none; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
button:hover { |
||||
|
background-color: #0056b3; |
||||
|
} |
||||
|
button:disabled { |
||||
|
background-color: #ccc; |
||||
|
cursor: not-allowed; |
||||
|
} |
||||
|
.output-section { |
||||
|
margin-top: 30px; |
||||
|
border-top: 1px solid #eee; |
||||
|
padding-top: 20px; |
||||
|
} |
||||
|
.output-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: bold; |
||||
|
margin-bottom: 15px; |
||||
|
color: #333; |
||||
|
} |
||||
|
.output-text { |
||||
|
padding: 15px; |
||||
|
background-color: #f8f9fa; |
||||
|
border-radius: 4px; |
||||
|
border: 1px solid #e9ecef; |
||||
|
min-height: 100px; |
||||
|
font-size: 16px; |
||||
|
line-height: 1.8; |
||||
|
color: #333; |
||||
|
white-space: pre-wrap; |
||||
|
} |
||||
|
.status { |
||||
|
margin-top: 20px; |
||||
|
padding: 15px; |
||||
|
background-color: #d1ecf1; |
||||
|
border-radius: 4px; |
||||
|
color: #0c5460; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
audio { |
||||
|
margin-top: 20px; |
||||
|
width: 100%; |
||||
|
} |
||||
|
.example-btn { |
||||
|
margin-left: 10px; |
||||
|
background-color: #28a745; |
||||
|
} |
||||
|
.example-btn:hover { |
||||
|
background-color: #218838; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="container"> |
||||
|
<h1>🤖 智能客服 - 文本 + 语音</h1> |
||||
|
|
||||
|
<div class="input-group"> |
||||
|
<label style="display: block; margin-bottom: 10px; font-size: 16px;">请输入您的问题:</label> |
||||
|
<textarea id="questionInput" placeholder="请输入您的问题,例如:什么是半仓?"></textarea> |
||||
|
</div> |
||||
|
|
||||
|
<div class="input-group"> |
||||
|
<button id="generateBtn" onclick="askQuestion()">提问</button> |
||||
|
<button class="example-btn" onclick="fillExample()">示例问题</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="status" id="status"> |
||||
|
状态:等待提问 |
||||
|
</div> |
||||
|
|
||||
|
<div class="output-section"> |
||||
|
<div class="output-title">📝 回答内容</div> |
||||
|
<div class="output-text" id="outputText"> |
||||
|
(回答将在这里显示) |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<audio id="audioPlayer" controls></audio> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
const exampleQuestion = "什么是半仓?"; |
||||
|
|
||||
|
function fillExample() { |
||||
|
document.getElementById('questionInput').value = exampleQuestion; |
||||
|
} |
||||
|
|
||||
|
async function askQuestion() { |
||||
|
const question = document.getElementById('questionInput').value.trim(); |
||||
|
const statusDiv = document.getElementById('status'); |
||||
|
const outputText = document.getElementById('outputText'); |
||||
|
const audioPlayer = document.getElementById('audioPlayer'); |
||||
|
const generateBtn = document.getElementById('generateBtn'); |
||||
|
|
||||
|
if (!question) { |
||||
|
alert('请输入您的问题!'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
generateBtn.disabled = true; |
||||
|
outputText.textContent = ''; |
||||
|
statusDiv.textContent = '状态:正在获取回答...'; |
||||
|
console.log('开始提问:', question); |
||||
|
|
||||
|
let fullText = ''; |
||||
|
let eventSource = null; |
||||
|
|
||||
|
try { |
||||
|
eventSource = new EventSource(`/customer/service2?question=${encodeURIComponent(question)}`); |
||||
|
|
||||
|
eventSource.onmessage = (event) => { |
||||
|
console.log('收到原始数据:', event.data); |
||||
|
|
||||
|
const dataStr = event.data; |
||||
|
|
||||
|
if (!dataStr || dataStr.trim() === '') { |
||||
|
console.log('跳过空数据'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const data = JSON.parse(dataStr); |
||||
|
const text = data.text; |
||||
|
if (text) { |
||||
|
fullText += text; |
||||
|
outputText.textContent += text; |
||||
|
outputText.scrollTop = outputText.scrollHeight; |
||||
|
statusDiv.textContent = `状态:正在获取回答... (已收到 ${fullText.length} 字)`; |
||||
|
console.log('解析到文本:', text); |
||||
|
} |
||||
|
} catch (parseError) { |
||||
|
console.error('JSON解析失败:', parseError, '原始数据:', dataStr); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
eventSource.onerror = (error) => { |
||||
|
console.log('EventSource 连接关闭:', error); |
||||
|
eventSource.close(); |
||||
|
|
||||
|
if (fullText.length > 0) { |
||||
|
statusDiv.textContent = '状态:回答获取完成,正在生成语音...'; |
||||
|
console.log('完整回答:', fullText); |
||||
|
|
||||
|
generateAndPlayAudio(fullText); |
||||
|
} else { |
||||
|
statusDiv.textContent = '状态:未获取到回答'; |
||||
|
generateBtn.disabled = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('获取回答错误:', error); |
||||
|
statusDiv.textContent = `状态:获取回答失败 - ${error.message}`; |
||||
|
generateBtn.disabled = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function generateAndPlayAudio(text) { |
||||
|
const statusDiv = document.getElementById('status'); |
||||
|
const audioPlayer = document.getElementById('audioPlayer'); |
||||
|
const generateBtn = document.getElementById('generateBtn'); |
||||
|
|
||||
|
statusDiv.textContent = '状态:正在生成语音...'; |
||||
|
console.log('开始语音合成,文本长度:', text.length); |
||||
|
|
||||
|
try { |
||||
|
const url = `/customer/synthesize?text=${encodeURIComponent(text)}`; |
||||
|
|
||||
|
audioPlayer.src = url; |
||||
|
|
||||
|
audioPlayer.onloadstart = () => { |
||||
|
statusDiv.textContent = '状态:开始接收音频流...'; |
||||
|
console.log('开始接收音频流'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onloadeddata = () => { |
||||
|
statusDiv.textContent = '状态:数据加载中,准备播放...'; |
||||
|
console.log('音频数据加载完成,可以开始播放'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onplaying = () => { |
||||
|
statusDiv.textContent = '状态:正在播放(流式)'; |
||||
|
console.log('开始播放音频'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onwaiting = () => { |
||||
|
statusDiv.textContent = '状态:缓冲中...'; |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.oncanplay = () => { |
||||
|
statusDiv.textContent = '状态:正在播放(流畅)'; |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onended = () => { |
||||
|
statusDiv.textContent = '状态:播放完成'; |
||||
|
generateBtn.disabled = false; |
||||
|
console.log('播放完成'); |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.onerror = (e) => { |
||||
|
console.error('音频播放错误:', e); |
||||
|
statusDiv.textContent = `状态:音频播放失败 - ${audioPlayer.error ? audioPlayer.error.message : '未知错误'}`; |
||||
|
generateBtn.disabled = false; |
||||
|
}; |
||||
|
|
||||
|
audioPlayer.play(); |
||||
|
console.log('已调用 audio.play()'); |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('语音合成错误:', error); |
||||
|
statusDiv.textContent = `状态:出错了 - ${error.message}`; |
||||
|
generateBtn.disabled = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
window.onload = () => { |
||||
|
console.log('页面加载完成'); |
||||
|
}; |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -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 |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue