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