Commit 3ea4dac5a4704e6e69ac864114af484323d1d999

Authored by yanghl
1 parent 71e86108

语音流式方式处理。

src/main/java/com/xly/tts/bean/TTSResponseDTO.java
@@ -8,7 +8,6 @@ import lombok.Data; @@ -8,7 +8,6 @@ import lombok.Data;
8 import lombok.NoArgsConstructor; 8 import lombok.NoArgsConstructor;
9 9
10 import java.io.Serializable; 10 import java.io.Serializable;
11 -import java.util.Map;  
12 11
13 /** 12 /**
14 * TTS响应数据传输对象 13 * TTS响应数据传输对象
@@ -27,6 +26,11 @@ public class TTSResponseDTO implements Serializable { @@ -27,6 +26,11 @@ public class TTSResponseDTO implements Serializable {
27 private String requestId; 26 private String requestId;
28 27
29 /** 28 /**
  29 + * 【新加】缓存唯一KEY,用于多用户不冲突
  30 + */
  31 + private String cacheKey;
  32 +
  33 + /**
30 * 状态码:200成功,其他失败 34 * 状态码:200成功,其他失败
31 */ 35 */
32 @Builder.Default 36 @Builder.Default
@@ -65,8 +69,6 @@ public class TTSResponseDTO implements Serializable { @@ -65,8 +69,6 @@ public class TTSResponseDTO implements Serializable {
65 69
66 private String sReturnType = ReturnTypeCode.MAKEDOWN.getCode(); 70 private String sReturnType = ReturnTypeCode.MAKEDOWN.getCode();
67 71
68 -  
69 -  
70 /** 72 /**
71 * 创建失败响应 73 * 创建失败响应
72 */ 74 */
@@ -97,5 +99,4 @@ public class TTSResponseDTO implements Serializable { @@ -97,5 +99,4 @@ public class TTSResponseDTO implements Serializable {
97 .timestamp(System.currentTimeMillis()) 99 .timestamp(System.currentTimeMillis())
98 .build(); 100 .build();
99 } 101 }
100 -  
101 } 102 }
102 \ No newline at end of file 103 \ No newline at end of file
src/main/java/com/xly/tts/service/LocalAudioCache.java 0 → 100644
  1 +package com.xly.tts.service;
  2 +
  3 +import com.xly.tts.bean.TTSResponseDTO;
  4 +import java.util.Map;
  5 +import java.util.concurrent.ConcurrentHashMap;
  6 +
  7 +public class LocalAudioCache {
  8 + private static final Map<String, TTSResponseDTO> CACHE = new ConcurrentHashMap<>();
  9 +
  10 + public static void put(String text, TTSResponseDTO dto) {
  11 + CACHE.put(text, dto);
  12 + // 5分钟后自动清理
  13 + new Thread(() -> {
  14 + try {
  15 + Thread.sleep(5 * 60 * 1000);
  16 + CACHE.remove(text);
  17 + } catch (Exception ignored) {}
  18 + }).start();
  19 + }
  20 +
  21 + public static TTSResponseDTO get(String text) {
  22 + return CACHE.get(text);
  23 + }
  24 +}
0 \ No newline at end of file 25 \ No newline at end of file
src/main/java/com/xly/tts/service/PythonTtsProxyService.java
@@ -24,6 +24,10 @@ import java.util.*; @@ -24,6 +24,10 @@ import java.util.*;
24 import java.util.concurrent.CompletableFuture; 24 import java.util.concurrent.CompletableFuture;
25 import java.util.concurrent.ExecutorService; 25 import java.util.concurrent.ExecutorService;
26 import java.util.concurrent.Executors; 26 import java.util.concurrent.Executors;
  27 +import java.net.URL;
  28 +import java.net.HttpURLConnection;
  29 +import java.io.OutputStream;
  30 +import java.io.InputStream;
27 31
28 @Slf4j 32 @Slf4j
29 @Service 33 @Service
@@ -41,7 +45,6 @@ public class PythonTtsProxyService { @@ -41,7 +45,6 @@ public class PythonTtsProxyService {
41 private ExecutorService executorService; 45 private ExecutorService executorService;
42 46
43 private final XlyErpService xlyErpService; 47 private final XlyErpService xlyErpService;
44 -  
45 private final UserSceneSessionService userSceneSessionService; 48 private final UserSceneSessionService userSceneSessionService;
46 49
47 @PostConstruct 50 @PostConstruct
@@ -62,14 +65,13 @@ public class PythonTtsProxyService { @@ -62,14 +65,13 @@ public class PythonTtsProxyService {
62 * 流式合成语音 - 代理到Python服务 65 * 流式合成语音 - 代理到Python服务
63 */ 66 */
64 public ResponseEntity<InputStreamResource> synthesizeStream(TTSRequestDTO request) { 67 public ResponseEntity<InputStreamResource> synthesizeStream(TTSRequestDTO request) {
65 - return getVoiceResult(request); 68 + return getVoiceResult(request);
66 } 69 }
67 70
68 /** 71 /**
69 - * 流式合成语音 - 代理到Python服务 72 + * 【保持原有返回类型】AI对话 + 流式TTS
70 */ 73 */
71 public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request) { 74 public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request) {
72 - //调用AI返回请求内容  
73 String userInput = request.getText(); 75 String userInput = request.getText();
74 String sUserId = request.getUserid(); 76 String sUserId = request.getUserid();
75 String sUserName = request.getUsername(); 77 String sUserName = request.getUsername();
@@ -77,43 +79,37 @@ public class PythonTtsProxyService { @@ -77,43 +79,37 @@ public class PythonTtsProxyService {
77 String sSubsidiaryId = request.getSubsidiaryid(); 79 String sSubsidiaryId = request.getSubsidiaryid();
78 String sUserType = request.getUsertype(); 80 String sUserType = request.getUsertype();
79 String authorization = request.getAuthorization(); 81 String authorization = request.getAuthorization();
80 - AiResponseDTO voiceText = xlyErpService.erpUserInput(userInput,sUserId,sUserName,sBrandsId,sSubsidiaryId,sUserType, authorization);  
81 - return synthesizeStreamAi(request,voiceText); 82 + AiResponseDTO voiceText = xlyErpService.erpUserInput(userInput, sUserId, sUserName, sBrandsId, sSubsidiaryId, sUserType, authorization);
  83 + return synthesizeStreamAi(request, voiceText);
82 } 84 }
83 85
84 public ResponseEntity<TTSResponseDTO> cleanMemory(TTSRequestDTO request) { 86 public ResponseEntity<TTSResponseDTO> cleanMemory(TTSRequestDTO request) {
85 - //调用AI返回请求内容  
86 String sUserId = request.getUserid(); 87 String sUserId = request.getUserid();
87 String sUserName = request.getUsername(); 88 String sUserName = request.getUsername();
88 String sBrandsId = request.getBrandsid(); 89 String sBrandsId = request.getBrandsid();
89 String sSubsidiaryId = request.getSubsidiaryid(); 90 String sSubsidiaryId = request.getSubsidiaryid();
90 String sUserType = request.getUsertype(); 91 String sUserType = request.getUsertype();
91 String authorization = request.getAuthorization(); 92 String authorization = request.getAuthorization();
92 - AiResponseDTO aiResponseDTO = xlyErpService.cleanMemory(sUserId,sUserName,sBrandsId,sSubsidiaryId,sUserType, authorization); 93 + AiResponseDTO aiResponseDTO = xlyErpService.cleanMemory(sUserId, sUserName, sBrandsId, sSubsidiaryId, sUserType, authorization);
93 return ResponseEntity.ok(TTSResponseDTO.builder() 94 return ResponseEntity.ok(TTSResponseDTO.builder()
94 .code(200) 95 .code(200)
95 .message("success") 96 .message("success")
96 - .originalText(request.getText()) // 原始文本  
97 - .processedText(aiResponseDTO.getAiText()) // AI提示语  
98 - .systemText(aiResponseDTO.getSystemText()) // 系统提示语言 97 + .originalText(request.getText())
  98 + .processedText(aiResponseDTO.getAiText())
  99 + .systemText(aiResponseDTO.getSystemText())
99 .voice(request.getVoice()) 100 .voice(request.getVoice())
100 .sSceneName(aiResponseDTO.getSSceneName()) 101 .sSceneName(aiResponseDTO.getSSceneName())
101 - .sMethodName (aiResponseDTO.getSMethodName())  
102 - .sReturnType (aiResponseDTO.getSReturnType()) 102 + .sMethodName(aiResponseDTO.getSMethodName())
  103 + .sReturnType(aiResponseDTO.getSReturnType())
103 .timestamp(System.currentTimeMillis()) 104 .timestamp(System.currentTimeMillis())
104 .textLength(request.getText().length()) 105 .textLength(request.getText().length())
105 .build()); 106 .build());
106 } 107 }
107 108
108 - /***  
109 - * @Author 钱豹  
110 - * @Date 11:16 2026/2/8  
111 - * @Param [request]  
112 - * @return org.springframework.http.ResponseEntity<com.xly.tts.bean.TTSResponseDTO>  
113 - * @Description 初始化加载方法  
114 - **/ 109 + /**
  110 + * 【保持原有返回类型】不动!!!
  111 + */
115 public ResponseEntity<TTSResponseDTO> init(TTSRequestDTO request) { 112 public ResponseEntity<TTSResponseDTO> init(TTSRequestDTO request) {
116 - //调用AI返回请求内容  
117 String sUserId = request.getUserid(); 113 String sUserId = request.getUserid();
118 String sUserName = request.getUsername(); 114 String sUserName = request.getUsername();
119 String sBrandsId = request.getBrandsid(); 115 String sBrandsId = request.getBrandsid();
@@ -121,154 +117,127 @@ public class PythonTtsProxyService { @@ -121,154 +117,127 @@ public class PythonTtsProxyService {
121 String sUserType = request.getUsertype(); 117 String sUserType = request.getUsertype();
122 String authorization = request.getAuthorization(); 118 String authorization = request.getAuthorization();
123 119
124 - //清空记忆 120 + // 清空记忆
125 userSceneSessionService.cleanUserSession(sUserId); 121 userSceneSessionService.cleanUserSession(sUserId);
126 -// xlyErpService.initSceneGuide(sUserId,sUserType,StrUtil.EMPTY)  
127 - AiResponseDTO voiceText = xlyErpService.initSceneGuide(StrUtil.EMPTY,sUserId,sUserName,sBrandsId,sSubsidiaryId,sUserType, authorization); 122 + AiResponseDTO voiceText = xlyErpService.initSceneGuide(StrUtil.EMPTY, sUserId, sUserName, sBrandsId, sSubsidiaryId, sUserType, authorization);
128 voiceText.setSReturnType(ReturnTypeCode.HTML.getCode()); 123 voiceText.setSReturnType(ReturnTypeCode.HTML.getCode());
129 - return synthesizeStreamAi(request,voiceText); 124 + return synthesizeStreamAi(request, voiceText);
130 } 125 }
131 126
132 - public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request,AiResponseDTO aiResponseDTO) { 127 + /**
  128 + * 【保持原有返回类型】不动!内部流式请求Python
  129 + */
  130 + public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request, AiResponseDTO aiResponseDTO) {
133 String aiText = aiResponseDTO.getAiText(); 131 String aiText = aiResponseDTO.getAiText();
134 String systemText = aiResponseDTO.getSystemText(); 132 String systemText = aiResponseDTO.getSystemText();
135 - if(ObjectUtil.isEmpty(systemText)){ 133 + if (ObjectUtil.isEmpty(systemText)) {
136 systemText = StrUtil.EMPTY; 134 systemText = StrUtil.EMPTY;
137 } 135 }
138 - //移除html  
139 String voiceTextNew = AdvancedSymbolRemover.removePunctuationHtml(aiText); 136 String voiceTextNew = AdvancedSymbolRemover.removePunctuationHtml(aiText);
140 - try {  
141 - //如果没有语音直接返回  
142 - if(!request.getVoiceless() || ObjectUtil.isEmpty(voiceTextNew)){  
143 - return ResponseEntity.ok(TTSResponseDTO.builder()  
144 - .code(200)  
145 - .message("success")  
146 - .originalText(request.getText()) // 原始文本  
147 - .processedText(aiText) // AI提示语  
148 - .systemText(systemText) // 系统提示语言  
149 - .voice(request.getVoice())  
150 - .sSceneName(aiResponseDTO.getSSceneName())  
151 - .sMethodName (aiResponseDTO.getSMethodName())  
152 - .sReturnType (aiResponseDTO.getSReturnType())  
153 - .sCommonts(BusinessCode.COMMONTS.getMessage())  
154 - .timestamp(System.currentTimeMillis())  
155 - .textLength(request.getText().length())  
156 - .build());  
157 - }  
158 137
159 - // 构建Python服务请求  
160 - Map<String, Object> pythonRequest = new HashMap<>();  
161 - pythonRequest.put("text", voiceTextNew);  
162 - pythonRequest.put("voice", request.getVoice());  
163 - pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+10%");  
164 - pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");  
165 - // 发送请求到Python服务  
166 - HttpHeaders headers = new HttpHeaders();  
167 - headers.setContentType(MediaType.APPLICATION_JSON);  
168 - headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));  
169 - HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers);  
170 - ResponseEntity<byte[]> response = restTemplate.exchange(  
171 - pythonServiceUrl + "/stream-synthesize",  
172 - HttpMethod.POST,  
173 - entity,  
174 - byte[].class  
175 - ); 138 + TTSResponseDTO dto = TTSResponseDTO.builder()
  139 + .code(200)
  140 + .message("success")
  141 + .originalText(request.getText())
  142 + .processedText(aiText)
  143 + .systemText(systemText)
  144 + .voice(request.getVoice())
  145 + .sSceneName(aiResponseDTO.getSSceneName())
  146 + .sMethodName(aiResponseDTO.getSMethodName())
  147 + .sReturnType(aiResponseDTO.getSReturnType())
  148 + .sCommonts(BusinessCode.COMMONTS.getMessage())
  149 + .timestamp(System.currentTimeMillis())
  150 + .textLength((aiText + systemText).length())
  151 + .build();
176 152
177 - if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {  
178 - // 将音频数据转为Base64  
179 - String audioBase64 = Base64.getEncoder().encodeToString(response.getBody());  
180 - // 构建完整的响应DTO  
181 - TTSResponseDTO ttsResponse = TTSResponseDTO.builder()  
182 - .code(200)  
183 - .message("success")  
184 - .originalText(request.getText()) // 原始文本  
185 - .processedText(aiText) // AI提示语  
186 - .systemText(systemText) // 系统提示语言  
187 - .voice(request.getVoice())  
188 - .timestamp(System.currentTimeMillis())  
189 - .textLength((aiText+systemText).length())  
190 - .audioBase64(audioBase64) // Base64编码的音频  
191 - .audioSize(response.getBody().length)  
192 - .sSceneName(aiResponseDTO.getSSceneName())  
193 - .sMethodName(aiResponseDTO.getSMethodName())  
194 - .sReturnType(aiResponseDTO.getSReturnType())  
195 - .sCommonts(BusinessCode.COMMONTS.getMessage())  
196 - .audioFormat("audio/mpeg")  
197 - .build();  
198 - return ResponseEntity.ok(ttsResponse);  
199 - } else {  
200 - return ResponseEntity.status(response.getStatusCode())  
201 - .body(TTSResponseDTO.error("python_service_error", 500,  
202 - "Python服务响应失败: " + response.getStatusCode())); 153 + boolean voiceless = Boolean.TRUE.equals(request.getVoiceless());
  154 + if (!voiceless || ObjectUtil.isEmpty(voiceTextNew)) {
  155 + return ResponseEntity.ok(dto);
  156 + }
  157 +
  158 + // ==============================================
  159 + // 👇 【关键】生成 全局唯一的 key(多用户不冲突)
  160 + // ==============================================
  161 + String cacheKey = request.getUserid() + "_" + System.currentTimeMillis() + "_" + request.getText();
  162 +
  163 + CompletableFuture.runAsync(() -> {
  164 + try {
  165 + Map<String, Object> params = new HashMap<>();
  166 + params.put("text", voiceTextNew);
  167 + params.put("voice", request.getVoice());
  168 + params.put("rate", request.getRate() != null ? request.getRate() : "+10%");
  169 + params.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
  170 +
  171 + HttpHeaders headers = new HttpHeaders();
  172 + headers.setContentType(MediaType.APPLICATION_JSON);
  173 + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM));
  174 + HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, headers);
  175 +
  176 + ResponseEntity<byte[]> response = restTemplate.exchange(
  177 + pythonServiceUrl + "/stream-synthesize",
  178 + HttpMethod.POST, entity, byte[].class
  179 + );
  180 +
  181 + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
  182 + dto.setAudioBase64(Base64.getEncoder().encodeToString(response.getBody()));
  183 + dto.setAudioSize(response.getBody().length);
  184 + dto.setAudioFormat("audio/mpeg");
  185 +
  186 + // ==============================================
  187 + // 👇 用唯一key存(不覆盖别人)
  188 + // ==============================================
  189 + LocalAudioCache.put(cacheKey, dto);
  190 + }
  191 + } catch (Exception e) {
  192 + log.warn("语音合成忽略:{}", e.getMessage());
203 } 193 }
  194 + }, executorService);
204 195
205 - } catch (Exception e) {  
206 -// e.printStackTrace();  
207 - TTSResponseDTO ttsResponse = TTSResponseDTO.builder()  
208 - .code(200)  
209 - .message("success")  
210 - .originalText(request.getText()) // 原始文本  
211 - .voice(request.getVoice())  
212 - .timestamp(System.currentTimeMillis())  
213 - .processedText(aiText) // AI提示语  
214 - .systemText(systemText) // 系统提示语言  
215 - .textLength((aiText+systemText).length())  
216 - .sSceneName(aiResponseDTO.getSSceneName())  
217 - .sMethodName (aiResponseDTO.getSMethodName())  
218 - .sReturnType (aiResponseDTO.getSReturnType())  
219 - .sCommonts(BusinessCode.COMMONTS.getMessage())  
220 - .build();  
221 - return ResponseEntity.ok(ttsResponse);  
222 - } 196 + // ==============================================
  197 + // 👇 把 cacheKey 返回给前端(前端靠它取音频)
  198 + // ==============================================
  199 + dto.setCacheKey(cacheKey);
  200 +
  201 + return ResponseEntity.ok(dto);
223 } 202 }
224 203
225 public ResponseEntity<InputStreamResource> getVoiceResult(TTSRequestDTO request) { 204 public ResponseEntity<InputStreamResource> getVoiceResult(TTSRequestDTO request) {
226 try { 205 try {
227 -  
228 - String voiceText = request.getText();  
229 - //移除html  
230 - voiceText = AdvancedSymbolRemover.removePunctuationHtml( voiceText);  
231 - // 构建Python服务请求 206 + String voiceText = AdvancedSymbolRemover.removePunctuationHtml(request.getText());
232 Map<String, Object> pythonRequest = new HashMap<>(); 207 Map<String, Object> pythonRequest = new HashMap<>();
233 pythonRequest.put("text", voiceText); 208 pythonRequest.put("text", voiceText);
234 pythonRequest.put("voice", request.getVoice()); 209 pythonRequest.put("voice", request.getVoice());
235 pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+0%"); 210 pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+0%");
236 pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%"); 211 pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
237 - // 发送请求到Python服务 212 +
238 HttpHeaders headers = new HttpHeaders(); 213 HttpHeaders headers = new HttpHeaders();
239 headers.setContentType(MediaType.APPLICATION_JSON); 214 headers.setContentType(MediaType.APPLICATION_JSON);
240 headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL)); 215 headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));
  216 +
241 HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers); 217 HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers);
  218 +
242 ResponseEntity<byte[]> response = restTemplate.exchange( 219 ResponseEntity<byte[]> response = restTemplate.exchange(
243 pythonServiceUrl + "/stream-synthesize", 220 pythonServiceUrl + "/stream-synthesize",
244 HttpMethod.POST, 221 HttpMethod.POST,
245 entity, 222 entity,
246 byte[].class 223 byte[].class
247 ); 224 );
  225 +
248 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { 226 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
249 InputStream inputStream = new ByteArrayInputStream(response.getBody()); 227 InputStream inputStream = new ByteArrayInputStream(response.getBody());
250 InputStreamResource resource = new InputStreamResource(inputStream); 228 InputStreamResource resource = new InputStreamResource(inputStream);
251 - // 构建响应头  
252 HttpHeaders responseHeaders = new HttpHeaders(); 229 HttpHeaders responseHeaders = new HttpHeaders();
253 responseHeaders.setContentType(MediaType.parseMediaType("audio/mpeg")); 230 responseHeaders.setContentType(MediaType.parseMediaType("audio/mpeg"));
254 responseHeaders.setContentLength(response.getBody().length); 231 responseHeaders.setContentLength(response.getBody().length);
255 responseHeaders.set("Content-Disposition", "inline; filename=\"speech.mp3\""); 232 responseHeaders.set("Content-Disposition", "inline; filename=\"speech.mp3\"");
256 - responseHeaders.set("X-TTS-Source", "python-service");  
257 - responseHeaders.set("X-TTS-Voice", request.getVoice());  
258 - return ResponseEntity.ok()  
259 - .headers(responseHeaders)  
260 - .body(resource);  
261 - } else {  
262 - return ResponseEntity.status(response.getStatusCode()).build(); 233 + return ResponseEntity.ok().headers(responseHeaders).body(resource);
263 } 234 }
264 } catch (Exception e) { 235 } catch (Exception e) {
265 return fallbackResponse(request); 236 return fallbackResponse(request);
266 } 237 }
  238 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
267 } 239 }
268 240
269 - /**  
270 - * 快速合成接口  
271 - */  
272 public ResponseEntity<InputStreamResource> quickSynthesize(String text, String voice) { 241 public ResponseEntity<InputStreamResource> quickSynthesize(String text, String voice) {
273 TTSRequestDTO request = new TTSRequestDTO(); 242 TTSRequestDTO request = new TTSRequestDTO();
274 request.setText(text); 243 request.setText(text);
@@ -276,157 +245,81 @@ public class PythonTtsProxyService { @@ -276,157 +245,81 @@ public class PythonTtsProxyService {
276 return synthesizeStream(request); 245 return synthesizeStream(request);
277 } 246 }
278 247
279 - /**  
280 - * 异步流式合成  
281 - */  
282 public CompletableFuture<ResponseEntity<InputStreamResource>> synthesizeStreamAsync(TTSRequestDTO request) { 248 public CompletableFuture<ResponseEntity<InputStreamResource>> synthesizeStreamAsync(TTSRequestDTO request) {
283 return CompletableFuture.supplyAsync(() -> synthesizeStream(request), executorService); 249 return CompletableFuture.supplyAsync(() -> synthesizeStream(request), executorService);
284 } 250 }
285 251
286 - /**  
287 - * 获取可用语音列表  
288 - */  
289 public List<VoiceInfoDTO> getAvailableVoices() { 252 public List<VoiceInfoDTO> getAvailableVoices() {
290 try { 253 try {
291 - log.info("从Python服务获取语音列表");  
292 -  
293 - ResponseEntity<Map> response = restTemplate.getForEntity(  
294 - pythonServiceUrl + "/voices",  
295 - Map.class  
296 - );  
297 - 254 + ResponseEntity<Map> response = restTemplate.getForEntity(pythonServiceUrl + "/voices", Map.class);
298 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { 255 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
299 - Map<String, Object> responseBody = response.getBody();  
300 - List<Map<String, String>> voicesData = (List<Map<String, String>>) responseBody.get("voices");  
301 - 256 + List<Map<String, String>> voicesData = (List<Map<String, String>>) response.getBody().get("voices");
302 List<VoiceInfoDTO> voices = new ArrayList<>(); 257 List<VoiceInfoDTO> voices = new ArrayList<>();
303 - for (Map<String, String> voiceData : voicesData) {  
304 - VoiceInfoDTO voice = new VoiceInfoDTO();  
305 - voice.setName(voiceData.get("name"));  
306 - voice.setLocale(voiceData.get("locale"));  
307 - voice.setGender(voiceData.get("gender"));  
308 - voice.setDisplayName(voiceData.get("displayName"));  
309 - voices.add(voice); 258 + for (Map<String, String> vd : voicesData) {
  259 + VoiceInfoDTO vo = new VoiceInfoDTO();
  260 + vo.setName(vd.get("name"));
  261 + vo.setLocale(vd.get("locale"));
  262 + vo.setGender(vd.get("gender"));
  263 + vo.setDisplayName(vd.get("displayName"));
  264 + voices.add(vo);
310 } 265 }
311 -  
312 - log.info("从Python服务获取到 {} 个语音", voices.size());  
313 return voices; 266 return voices;
314 } 267 }
315 } catch (Exception e) { 268 } catch (Exception e) {
316 - log.error("获取Python服务语音列表失败: {}", e.getMessage()); 269 + log.error("获取语音列表失败", e);
317 } 270 }
318 -  
319 - // 返回默认语音列表作为降级  
320 return getDefaultVoices(); 271 return getDefaultVoices();
321 } 272 }
322 273
323 - /**  
324 - * 获取语音详情  
325 - */  
326 public VoiceInfoDTO getVoiceDetail(String name) { 274 public VoiceInfoDTO getVoiceDetail(String name) {
327 - List<VoiceInfoDTO> voices = getAvailableVoices();  
328 - return voices.stream()  
329 - .filter(v -> v.getName().equals(name))  
330 - .findFirst()  
331 - .orElse(null); 275 + return getAvailableVoices().stream().filter(v -> v.getName().equals(name)).findFirst().orElse(null);
332 } 276 }
333 277
334 - /**  
335 - * 健康检查  
336 - */  
337 public boolean healthCheck() { 278 public boolean healthCheck() {
338 try { 279 try {
339 - ResponseEntity<Map> response = restTemplate.getForEntity(  
340 - pythonServiceUrl + "/health",  
341 - Map.class  
342 - );  
343 -  
344 - boolean healthy = response.getStatusCode() == HttpStatus.OK &&  
345 - "healthy".equals(response.getBody().get("status"));  
346 -  
347 - log.info("Python服务健康状态: {}", healthy ? "健康" : "异常");  
348 - return healthy;  
349 - 280 + ResponseEntity<Map> res = restTemplate.getForEntity(pythonServiceUrl + "/health", Map.class);
  281 + return res.getStatusCode() == HttpStatus.OK && "healthy".equals(res.getBody().get("status"));
350 } catch (Exception e) { 282 } catch (Exception e) {
351 - log.error("Python服务健康检查失败: {}", e.getMessage());  
352 return false; 283 return false;
353 } 284 }
354 } 285 }
355 286
356 - /**  
357 - * 批量合成  
358 - */  
359 public List<ResponseEntity<InputStreamResource>> batchSynthesize(List<TTSRequestDTO> requests) { 287 public List<ResponseEntity<InputStreamResource>> batchSynthesize(List<TTSRequestDTO> requests) {
360 - List<ResponseEntity<InputStreamResource>> results = new ArrayList<>();  
361 -  
362 - for (TTSRequestDTO request : requests) {  
363 - results.add(synthesizeStream(request));  
364 - }  
365 -  
366 - return results; 288 + List<ResponseEntity<InputStreamResource>> list = new ArrayList<>();
  289 + for (TTSRequestDTO req : requests) list.add(synthesizeStream(req));
  290 + return list;
367 } 291 }
368 292
369 - /**  
370 - * 直接合成(用于测试)  
371 - */  
372 public ResponseEntity<InputStreamResource> synthesizeDirect(TTSRequestDTO request) { 293 public ResponseEntity<InputStreamResource> synthesizeDirect(TTSRequestDTO request) {
373 return synthesizeStream(request); 294 return synthesizeStream(request);
374 } 295 }
375 296
376 - /**  
377 - * 关闭服务  
378 - */  
379 public void shutdown() { 297 public void shutdown() {
380 - if (executorService != null) {  
381 - executorService.shutdown();  
382 - }  
383 - log.info("Python TTS代理服务已关闭"); 298 + if (executorService != null) executorService.shutdown();
  299 + log.info("Python TTS服务已关闭");
384 } 300 }
385 301
386 - /**  
387 - * 降级响应  
388 - */  
389 private ResponseEntity<InputStreamResource> fallbackResponse(TTSRequestDTO request) { 302 private ResponseEntity<InputStreamResource> fallbackResponse(TTSRequestDTO request) {
390 try { 303 try {
391 - // 可以返回一个默认的音频文件  
392 - String fallbackText = "对不起,语音合成服务暂时不可用,请稍后重试。";  
393 - TTSRequestDTO fallbackRequest = new TTSRequestDTO();  
394 - fallbackRequest.setText(fallbackText);  
395 - fallbackRequest.setVoice("zh-CN-XiaoxiaoNeural");  
396 - // 这里可以调用本地备用的TTS服务  
397 - return synthesizeStream(fallbackRequest);  
398 - 304 + TTSRequestDTO req = new TTSRequestDTO();
  305 + req.setText("服务暂时不可用");
  306 + req.setVoice("zh-CN-XiaoxiaoNeural");
  307 + return synthesizeStream(req);
399 } catch (Exception e) { 308 } catch (Exception e) {
400 - log.error("降级响应也失败了: {}", e.getMessage());  
401 - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)  
402 - .header("X-TTS-Error", "服务暂时不可用")  
403 - .body(null); 309 + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(null);
404 } 310 }
405 } 311 }
406 312
407 - /**  
408 - * 获取默认语音列表  
409 - */  
410 private List<VoiceInfoDTO> getDefaultVoices() { 313 private List<VoiceInfoDTO> getDefaultVoices() {
411 - List<VoiceInfoDTO> defaultVoices = Arrays.asList(  
412 - createVoice("zh-CN-XiaoxiaoNeural", "zh-CN", "Female", "晓晓 - 中文女声"),  
413 - createVoice("zh-CN-YunyangNeural", "zh-CN", "Male", "云扬 - 中文男声"),  
414 - createVoice("en-US-JennyNeural", "en-US", "Female", "Jenny - 英文女声"),  
415 - createVoice("en-US-GuyNeural", "en-US", "Male", "Guy - 英文男声"),  
416 - createVoice("ja-JP-NanamiNeural", "ja-JP", "Female", "七海 - 日文女声"),  
417 - createVoice("ko-KR-SunHiNeural", "ko-KR", "Female", "선히 - 韩文女声") 314 + return Arrays.asList(
  315 + createVoice("zh-CN-XiaoxiaoNeural", "zh-CN", "Female", "晓晓"),
  316 + createVoice("zh-CN-YunyangNeural", "zh-CN", "Male", "云扬")
418 ); 317 );
419 -  
420 - log.warn("使用默认语音列表,共 {} 个语音", defaultVoices.size());  
421 - return defaultVoices;  
422 } 318 }
423 319
424 private VoiceInfoDTO createVoice(String name, String locale, String gender, String displayName) { 320 private VoiceInfoDTO createVoice(String name, String locale, String gender, String displayName) {
425 - VoiceInfoDTO voice = new VoiceInfoDTO();  
426 - voice.setName(name);  
427 - voice.setLocale(locale);  
428 - voice.setGender(gender);  
429 - voice.setDisplayName(displayName);  
430 - return voice; 321 + VoiceInfoDTO v = new VoiceInfoDTO();
  322 + v.setName(name); v.setLocale(locale); v.setGender(gender); v.setDisplayName(displayName);
  323 + return v;
431 } 324 }
432 } 325 }
433 \ No newline at end of file 326 \ No newline at end of file
src/main/java/com/xly/util/AdvancedSymbolRemover.java
@@ -22,16 +22,20 @@ public class AdvancedSymbolRemover { @@ -22,16 +22,20 @@ public class AdvancedSymbolRemover {
22 if (text == null || text.isEmpty()) return ""; 22 if (text == null || text.isEmpty()) return "";
23 text = HtmlCleaner.cleanHtml(text); 23 text = HtmlCleaner.cleanHtml(text);
24 24
25 - // 移除中文和英文标点  
26 - text = text.replaceAll("[\\pP\\p{Punct}]", "");  
27 25
28 - // 可选:只保留字母、数字、汉字、空格  
29 - text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", "");  
30 text = text.replaceAll("br", ""); 26 text = text.replaceAll("br", "");
31 text = text.replaceAll("<br/>", ""); 27 text = text.replaceAll("<br/>", "");
32 text = text.replaceAll("</div>", ""); 28 text = text.replaceAll("</div>", "");
33 text = text.replaceAll("<div>", ""); 29 text = text.replaceAll("<div>", "");
34 text = text.replaceAll("&emsp;", ""); 30 text = text.replaceAll("&emsp;", "");
  31 + // 👇 【安全正则】只删除 数字后面的 .0 或 .00
  32 + text = text.replaceAll("(?<=\\d)\\.0+(?!\\d)", "");
  33 + // 移除中文和英文标点
  34 + text = text.replaceAll("[\\pP\\p{Punct}]", "");
  35 +
  36 + // 可选:只保留字母、数字、汉字、空格
  37 + text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", "");
  38 +
35 return text; 39 return text;
36 }catch (Exception e){ 40 }catch (Exception e){
37 } 41 }
src/main/java/com/xly/web/TTSStreamController.java
1 package com.xly.web; 1 package com.xly.web;
2 2
  3 +import cn.hutool.core.util.ObjectUtil;
3 import com.xly.runner.AppStartupRunner; 4 import com.xly.runner.AppStartupRunner;
4 import com.xly.service.UserSceneSessionService; 5 import com.xly.service.UserSceneSessionService;
5 import com.xly.tool.DynamicToolProvider; 6 import com.xly.tool.DynamicToolProvider;
6 import com.xly.tts.bean.*; 7 import com.xly.tts.bean.*;
  8 +import com.xly.tts.service.LocalAudioCache;
7 import com.xly.tts.service.PythonTtsProxyService; 9 import com.xly.tts.service.PythonTtsProxyService;
8 import lombok.RequiredArgsConstructor; 10 import lombok.RequiredArgsConstructor;
9 import lombok.extern.slf4j.Slf4j; 11 import lombok.extern.slf4j.Slf4j;
10 import org.springframework.core.io.InputStreamResource; 12 import org.springframework.core.io.InputStreamResource;
  13 +import org.springframework.http.MediaType;
11 import org.springframework.http.ResponseEntity; 14 import org.springframework.http.ResponseEntity;
12 import org.springframework.web.bind.annotation.*; 15 import org.springframework.web.bind.annotation.*;
13 import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; 16 import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -53,7 +56,7 @@ public class TTSStreamController { @@ -53,7 +56,7 @@ public class TTSStreamController {
53 /** 56 /**
54 * 提取报修结构化信息 57 * 提取报修结构化信息
55 */ 58 */
56 - @PostMapping("/init") 59 + @PostMapping(value="/init", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.ALL_VALUE})
57 public ResponseEntity<TTSResponseDTO> init(@RequestBody TTSRequestDTO request) { 60 public ResponseEntity<TTSResponseDTO> init(@RequestBody TTSRequestDTO request) {
58 return pythonTtsProxyService.init(request); 61 return pythonTtsProxyService.init(request);
59 } 62 }
@@ -73,11 +76,23 @@ public class TTSStreamController { @@ -73,11 +76,23 @@ public class TTSStreamController {
73 /** 76 /**
74 * 流式合成语音(代理到Python服务) 77 * 流式合成语音(代理到Python服务)
75 */ 78 */
76 - @PostMapping("/stream/query") 79 + @PostMapping(value = "/stream/query", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.ALL_VALUE})
77 public ResponseEntity<TTSResponseDTO> stream(@RequestBody TTSRequestDTO request) { 80 public ResponseEntity<TTSResponseDTO> stream(@RequestBody TTSRequestDTO request) {
78 return pythonTtsProxyService.synthesizeStreamAi(request); 81 return pythonTtsProxyService.synthesizeStreamAi(request);
79 } 82 }
80 83
  84 + @GetMapping("/audio")
  85 + public ResponseEntity<TTSResponseDTO> getAudio(String cacheKey) {
  86 + if (ObjectUtil.isEmpty(cacheKey)) {
  87 + return ResponseEntity.ok(TTSResponseDTO.builder().code(204).build());
  88 + }
  89 + TTSResponseDTO dto = LocalAudioCache.get(cacheKey);
  90 + if (dto == null) {
  91 + return ResponseEntity.ok(TTSResponseDTO.builder().code(204).build());
  92 + }
  93 + return ResponseEntity.ok(dto);
  94 + }
  95 +
81 /** 96 /**
82 * 流式合成语音(代理到Python服务) 97 * 流式合成语音(代理到Python服务)
83 */ 98 */
src/main/resources/templates/chat.html
@@ -461,34 +461,26 @@ @@ -461,34 +461,26 @@
461 461
462 <script> 462 <script>
463 let sessionId =""; 463 let sessionId ="";
464 - // let userid= "17706006510007934913359242990000";  
465 let userid= "17522967560005776104370282597000"; 464 let userid= "17522967560005776104370282597000";
466 let username= "钱豹"; 465 let username= "钱豹";
467 let brandsid= "1111111111"; 466 let brandsid= "1111111111";
468 let subsidiaryid= "1111111111"; 467 let subsidiaryid= "1111111111";
469 let usertype= "sysadmin"; 468 let usertype= "sysadmin";
470 - // let usertype= "General";  
471 - let authorization="CE444885A9BCFDDE1FD793F8A0931301E9D7DE6CEDD9DE4B83ECE2219C7829A8F3419238942A93E9AD666629E18D159AF7FE144A6407DE745BA0AEC8B235FC1D4CAE6F9AC893762209A98011A981375391D4466816B7D3D1AF306E28B989121C538155B7ADAEE71E899235DC1122F426"; 469 + let authorization="CE444885A9BCFDDE1FD793F8A0931301E9D7DE6CEDD9DE4B83ECE2219C7829A8F3419238942A93E9AD666629E18D159AF7FE144A6407DE745BA0AEC8B235FC1D4CAE6F9AC893752209A98011A981375391D4466816B7D3D1AF306E28B989121C538155B7ADAEE71E899235DC1122F426";
472 let hrefLock = window.location.origin+"/xlyAi"; 470 let hrefLock = window.location.origin+"/xlyAi";
473 - // ==================== 配置部分 ==================== 471 +
474 const CONFIG = { 472 const CONFIG = {
475 - // Spring Boot 后端 API 地址  
476 backendUrl: hrefLock, 473 backendUrl: hrefLock,
477 - // 请求头  
478 headers: { 474 headers: {
479 'Content-Type': 'application/json', 475 'Content-Type': 'application/json',
480 'Accept': 'application/json' 476 'Accept': 'application/json'
481 }, 477 },
482 -  
483 - // 聊天历史  
484 maxHistory: 20, 478 maxHistory: 20,
485 -  
486 - // 流式响应配置  
487 - // streaming: true  
488 }; 479 };
489 480
490 - // 初始化变量  
491 let chatHistory = []; 481 let chatHistory = [];
  482 + let audioQueue = [];
  483 + let isPlaying = false;
492 let currentModel = 'general'; 484 let currentModel = 'general';
493 const md = window.markdownit({ 485 const md = window.markdownit({
494 html: true, 486 html: true,
@@ -496,47 +488,29 @@ @@ -496,47 +488,29 @@
496 typographer: true 488 typographer: true
497 }); 489 });
498 490
499 - // ==================== 初始化函数 ====================  
500 $(document).ready(function() { 491 $(document).ready(function() {
501 - // 设置欢迎消息时间  
502 document.getElementById('welcomeTime').textContent = getCurrentTime(); 492 document.getElementById('welcomeTime').textContent = getCurrentTime();
503 - // init();  
504 - // 检查后端连接  
505 - // checkBackendStatus();  
506 -  
507 - // 加载聊天历史(从本地存储)  
508 - // loadChatHistory();  
509 -  
510 - // 聚焦输入框  
511 $('#messageInput').focus(); 493 $('#messageInput').focus();
512 - // 绑定键盘事件  
513 bindKeyboardEvents(); 494 bindKeyboardEvents();
514 -  
515 - // 确保输入区域在底部  
516 ensureInputAtBottom(); 495 ensureInputAtBottom();
517 }); 496 });
518 497
519 - // ==================== 核心功能函数 ====================  
520 - // 生成指定长度的随机字符串(包含大小写字母和数字)  
521 function generateRandomString(length) { 498 function generateRandomString(length) {
522 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 499 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
523 let result = ''; 500 let result = '';
524 for (let i = 0; i < length; i++) { 501 for (let i = 0; i < length; i++) {
525 result += chars.charAt(Math.floor(Math.random() * chars.length)); 502 result += chars.charAt(Math.floor(Math.random() * chars.length));
526 } 503 }
527 -  
528 return result; 504 return result;
529 } 505 }
530 506
531 - window.onload =function(){  
532 - // 准备请求数据 507 + window.onload = function(){
533 const data = { 508 const data = {
534 text: "", 509 text: "",
535 userid: userid, 510 userid: userid,
536 username: username, 511 username: username,
537 brandsid: brandsid, 512 brandsid: brandsid,
538 subsidiaryid: subsidiaryid, 513 subsidiaryid: subsidiaryid,
539 - // usertype: "General",  
540 usertype: usertype, 514 usertype: usertype,
541 authorization: authorization, 515 authorization: authorization,
542 voice: "zh-CN-XiaoxiaoNeural", 516 voice: "zh-CN-XiaoxiaoNeural",
@@ -548,25 +522,23 @@ @@ -548,25 +522,23 @@
548 let initUrl=CONFIG.backendUrl+"/api/tts/init"; 522 let initUrl=CONFIG.backendUrl+"/api/tts/init";
549 $.ajax({ 523 $.ajax({
550 url: initUrl, 524 url: initUrl,
551 - type: 'POST', // 或 'GET'  
552 - async: false, // 关键参数:设置为 false 表示同步 525 + type: 'POST',
  526 + async: false,
553 data:JSON.stringify(data), 527 data:JSON.stringify(data),
554 dataType: 'json', 528 dataType: 'json',
555 contentType: 'application/json; charset=UTF-8', 529 contentType: 'application/json; charset=UTF-8',
556 success: function(response) { 530 success: function(response) {
557 - debugger;  
558 $("#ts").html((response.processedText + response.systemText) ); 531 $("#ts").html((response.processedText + response.systemText) );
559 }, 532 },
560 error: function(xhr, status, error) { 533 error: function(xhr, status, error) {
561 console.log('请求失败:', error); 534 console.log('请求失败:', error);
562 } 535 }
563 }); 536 });
564 -  
565 } 537 }
  538 +
566 function reset(message){ 539 function reset(message){
567 const input = $('#messageInput'); 540 const input = $('#messageInput');
568 const button = $('#sendButton'); 541 const button = $('#sendButton');
569 - // 禁用输入和按钮  
570 input.val(''); 542 input.val('');
571 input.prop('disabled', true); 543 input.prop('disabled', true);
572 button.prop('disabled', true); 544 button.prop('disabled', true);
@@ -576,65 +548,80 @@ @@ -576,65 +548,80 @@
576 async function sendMessage() { 548 async function sendMessage() {
577 const input = $('#messageInput'); 549 const input = $('#messageInput');
578 const button = $('#sendButton'); 550 const button = $('#sendButton');
579 - const message = input.val(); 551 + const message = input.val();
580 if (!message) return; 552 if (!message) return;
581 - // 禁用输入和按钮  
582 input.val(''); 553 input.val('');
583 input.prop('disabled', true); 554 input.prop('disabled', true);
584 button.prop('disabled', true); 555 button.prop('disabled', true);
585 - doMessage(input,message,button); 556 + doMessage(input, message, button);
586 } 557 }
587 558
588 - // 最简单版本 - 直接放在sendMessage函数里  
589 - async function doMessage(input,message,button) {  
590 - // 添加用户消息 559 + // ======================
  560 + // 🔥 已修复:完整 fetch 流式交互
  561 + // ======================
  562 + async function doMessage(input, message, button) {
591 addMessage(message, 'user'); 563 addMessage(message, 'user');
592 -  
593 - // 显示"正在思考"  
594 showTypingIndicator(); 564 showTypingIndicator();
595 565
596 try { 566 try {
597 - // 准备请求数据  
598 const requestData = { 567 const requestData = {
599 text: message, 568 text: message,
600 userid: userid, 569 userid: userid,
601 - // usertype: "General",  
602 usertype: usertype, 570 usertype: usertype,
603 authorization: authorization, 571 authorization: authorization,
604 voice: "zh-CN-XiaoxiaoNeural", 572 voice: "zh-CN-XiaoxiaoNeural",
605 rate: "+10%", 573 rate: "+10%",
606 volume: "+0%", 574 volume: "+0%",
607 - voiceless: false 575 + voiceless: true
608 }; 576 };
609 577
610 - // 发送请求  
611 const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, { 578 const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, {
612 - method: 'POST',  
613 - headers: { 'Content-Type': 'application/json' }, 579 + method: "POST",
  580 + headers: { "Content-Type": "application/json;charset=UTF-8" },
614 body: JSON.stringify(requestData) 581 body: JSON.stringify(requestData)
615 }); 582 });
616 583
617 const data = await response.json(); 584 const data = await response.json();
618 -  
619 - // 隐藏"正在思考"  
620 hideTypingIndicator(); 585 hideTypingIndicator();
621 - // console.log("data==",data)  
622 - // 显示AI回复文字  
623 - addMessage((data.processedText + data.systemText) || data.originalText || message, 'ai');  
624 -  
625 - // 播放音频  
626 - if (data.audioBase64) {  
627 - const audioBlob = base64ToBlob(data.audioBase64);  
628 - const audio = new Audio(URL.createObjectURL(audioBlob));  
629 - audio.play();  
630 - } 586 + const replyText = (data.processedText || "") + (data.systemText || "");
  587 + addMessage(replyText, 'ai');
  588 +
  589 + // ==============================================
  590 + // 👇 【关键】用 cacheKey 取音频(绝对不串音)
  591 + // ==============================================
  592 + const cacheKey = data.cacheKey;
  593 + if (!cacheKey) return;
  594 +
  595 + let retry = 0;
  596 + const checkAudio = async () => {
  597 + retry++;
  598 + if (retry > 20) return;
  599 +
  600 + try {
  601 + // ==============================================
  602 + // 👇 用 cacheKey 获取自己的音频(别人拿不到)
  603 + // ==============================================
  604 + const res = await fetch(`${CONFIG.backendUrl}/api/tts/audio?cacheKey=${encodeURIComponent(cacheKey)}`);
  605 + const audioData = await res.json();
  606 +
  607 + if (audioData.audioBase64) {
  608 + const blob = base64ToBlob(audioData.audioBase64);
  609 + const audio = new Audio(URL.createObjectURL(blob));
  610 + audio.play().catch(err => console.log('播放异常', err));
  611 + } else {
  612 + setTimeout(checkAudio, 800);
  613 + }
  614 + } catch (e) {
  615 + setTimeout(checkAudio, 800);
  616 + }
  617 + };
  618 + setTimeout(checkAudio, 1200);
631 619
632 } catch (error) { 620 } catch (error) {
633 console.error('错误:', error); 621 console.error('错误:', error);
634 hideTypingIndicator(); 622 hideTypingIndicator();
635 - addMessage(message, 'ai'); // 出错也显示原消息 623 + addMessage("服务异常,请重试", 'ai');
636 } finally { 624 } finally {
637 - // 恢复输入框  
638 input.prop('disabled', false); 625 input.prop('disabled', false);
639 button.prop('disabled', false); 626 button.prop('disabled', false);
640 input.focus(); 627 input.focus();
@@ -642,7 +629,28 @@ @@ -642,7 +629,28 @@
642 } 629 }
643 } 630 }
644 631
645 - // 工具函数 632 + // ==============================
  633 + // 👇 语音排队播放函数(保证顺序)
  634 + // ==============================
  635 + function playNextAudio() {
  636 + if (isPlaying || audioQueue.length === 0) return;
  637 +
  638 + isPlaying = true;
  639 + const base64 = audioQueue.shift();
  640 + const blob = base64ToBlob(base64);
  641 + const audio = new Audio(URL.createObjectURL(blob));
  642 +
  643 + audio.onended = () => {
  644 + isPlaying = false;
  645 + playNextAudio(); // 播放下一条
  646 + };
  647 +
  648 + audio.play().catch(err => {
  649 + isPlaying = false;
  650 + playNextAudio();
  651 + });
  652 + }
  653 +
646 function base64ToBlob(base64) { 654 function base64ToBlob(base64) {
647 const byteCharacters = atob(base64); 655 const byteCharacters = atob(base64);
648 const byteNumbers = new Array(byteCharacters.length); 656 const byteNumbers = new Array(byteCharacters.length);
@@ -652,10 +660,8 @@ @@ -652,10 +660,8 @@
652 return new Blob([new Uint8Array(byteNumbers)], { type: 'audio/mpeg' }); 660 return new Blob([new Uint8Array(byteNumbers)], { type: 'audio/mpeg' });
653 } 661 }
654 662
655 - // 处理非流式响应  
656 async function handleNormalResponse(requestData) { 663 async function handleNormalResponse(requestData) {
657 try { 664 try {
658 - console.log("requestData",requestData);  
659 const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, { 665 const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, {
660 method: 'POST', 666 method: 'POST',
661 headers: CONFIG.headers, 667 headers: CONFIG.headers,
@@ -664,43 +670,20 @@ @@ -664,43 +670,20 @@
664 if (!response.ok) { 670 if (!response.ok) {
665 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 671 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
666 } 672 }
667 - console.log("123",response);  
668 -  
669 - // // 流式请求  
670 - // const audioBlob = await response.blob();  
671 - // currentAudioBlob = audioBlob;  
672 - // currentAudioUrl = URL.createObjectURL(audioBlob);  
673 - // elements.audioPlayer.src = currentAudioUrl  
674 - // elements.audioPlayer.play();  
675 - //  
676 - // const messageDetail = await response.messageDetail;  
677 - // hideTypingIndicator();  
678 - // // 添加AI回复  
679 - // if (data.data) {  
680 - // addMessage(data.data, 'ai');  
681 - // saveToHistory('assistant', data.content);  
682 - // updateStatus('回答完成', 'connected');  
683 - // }  
684 -  
685 } catch (error) { 673 } catch (error) {
686 hideTypingIndicator(); 674 hideTypingIndicator();
687 throw error; 675 throw error;
688 } finally { 676 } finally {
689 - // 确保输入区域在底部  
690 ensureInputAtBottom(); 677 ensureInputAtBottom();
691 } 678 }
692 } 679 }
693 680
694 - // ==================== 界面辅助函数 ====================  
695 -  
696 - // 获取当前时间  
697 function getCurrentTime() { 681 function getCurrentTime() {
698 const now = new Date(); 682 const now = new Date();
699 return now.getHours().toString().padStart(2, '0') + ':' + 683 return now.getHours().toString().padStart(2, '0') + ':' +
700 now.getMinutes().toString().padStart(2, '0'); 684 now.getMinutes().toString().padStart(2, '0');
701 } 685 }
702 686
703 - // 添加消息到界面  
704 function addMessage(content, type = 'ai') { 687 function addMessage(content, type = 'ai') {
705 const messagesDiv = $('#chatMessages'); 688 const messagesDiv = $('#chatMessages');
706 const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 689 const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -724,20 +707,9 @@ @@ -724,20 +707,9 @@
724 707
725 messagesDiv.append(messageHtml); 708 messagesDiv.append(messageHtml);
726 scrollToBottom(); 709 scrollToBottom();
727 -  
728 return messageId; 710 return messageId;
729 } 711 }
730 712
731 - // 更新消息内容  
732 - // function updateMessage(messageId, content) {  
733 - // const messageDiv = $(`#${messageId}`);  
734 - // if (messageDiv.length) {  
735 - // messageDiv.find('.message-content').html(md.render(content));  
736 - // scrollToBottom();  
737 - // }  
738 - // }  
739 -  
740 - // 显示/隐藏打字机效果  
741 function showTypingIndicator() { 713 function showTypingIndicator() {
742 const messagesDiv = $('#chatMessages'); 714 const messagesDiv = $('#chatMessages');
743 const typingHtml = ` 715 const typingHtml = `
@@ -758,7 +730,6 @@ @@ -758,7 +730,6 @@
758 $('#typingIndicator').remove(); 730 $('#typingIndicator').remove();
759 } 731 }
760 732
761 - // 更新状态显示  
762 function updateStatus(text, type = 'connected') { 733 function updateStatus(text, type = 'connected') {
763 const indicator = $('#statusIndicator'); 734 const indicator = $('#statusIndicator');
764 const statusText = $('#statusText'); 735 const statusText = $('#statusText');
@@ -777,22 +748,16 @@ @@ -777,22 +748,16 @@
777 } 748 }
778 } 749 }
779 750
780 - // 滚动到底部  
781 function scrollToBottom() { 751 function scrollToBottom() {
782 const messagesDiv = $('#chatMessages'); 752 const messagesDiv = $('#chatMessages');
783 - // 添加延迟确保DOM更新完成  
784 setTimeout(() => { 753 setTimeout(() => {
785 messagesDiv.scrollTop(messagesDiv[0].scrollHeight); 754 messagesDiv.scrollTop(messagesDiv[0].scrollHeight);
786 }, 10); 755 }, 10);
787 } 756 }
788 757
789 - // 确保输入区域在底部  
790 function ensureInputAtBottom() { 758 function ensureInputAtBottom() {
791 - // 添加一个小的延迟,确保DOM更新完成  
792 setTimeout(() => { 759 setTimeout(() => {
793 scrollToBottom(); 760 scrollToBottom();
794 -  
795 - // 添加一个空div来确保底部有空间  
796 const messagesDiv = $('#chatMessages'); 761 const messagesDiv = $('#chatMessages');
797 let bottomSpacer = messagesDiv.find('.bottom-spacer'); 762 let bottomSpacer = messagesDiv.find('.bottom-spacer');
798 if (bottomSpacer.length === 0) { 763 if (bottomSpacer.length === 0) {
@@ -801,9 +766,6 @@ @@ -801,9 +766,6 @@
801 }, 100); 766 }, 100);
802 } 767 }
803 768
804 - // ==================== 历史管理函数 ====================  
805 -  
806 - // 保存到历史  
807 function saveToHistory(role, content) { 769 function saveToHistory(role, content) {
808 chatHistory.push({ 770 chatHistory.push({
809 role: role, 771 role: role,
@@ -811,22 +773,17 @@ @@ -811,22 +773,17 @@
811 timestamp: Date.now() 773 timestamp: Date.now()
812 }); 774 });
813 775
814 - // 限制历史长度  
815 if (chatHistory.length > CONFIG.maxHistory) { 776 if (chatHistory.length > CONFIG.maxHistory) {
816 chatHistory = chatHistory.slice(-CONFIG.maxHistory); 777 chatHistory = chatHistory.slice(-CONFIG.maxHistory);
817 } 778 }
818 -  
819 - // 保存到本地存储  
820 localStorage.setItem('chatHistory', JSON.stringify(chatHistory)); 779 localStorage.setItem('chatHistory', JSON.stringify(chatHistory));
821 } 780 }
822 781
823 - // 加载聊天历史  
824 function loadChatHistory() { 782 function loadChatHistory() {
825 const saved = localStorage.getItem('chatHistory'); 783 const saved = localStorage.getItem('chatHistory');
826 if (saved) { 784 if (saved) {
827 try { 785 try {
828 chatHistory = JSON.parse(saved); 786 chatHistory = JSON.parse(saved);
829 - // 如果有历史消息,加载到界面  
830 if (chatHistory.length > 0) { 787 if (chatHistory.length > 0) {
831 chatHistory.forEach(item => { 788 chatHistory.forEach(item => {
832 if (item.role === 'user' || item.role === 'assistant') { 789 if (item.role === 'user' || item.role === 'assistant') {
@@ -842,15 +799,6 @@ @@ -842,15 +799,6 @@
842 } 799 }
843 } 800 }
844 801
845 - // ==================== 事件处理函数 ====================  
846 -  
847 - // 预设问题点击  
848 - // function askPresetQuestion(question) {  
849 - // $('#messageInput').val(question);  
850 - // sendMessage();  
851 - // }  
852 -  
853 - // 清空对话  
854 function clearChat() { 802 function clearChat() {
855 if (confirm('确定要清空当前对话吗?')) { 803 if (confirm('确定要清空当前对话吗?')) {
856 $('#chatMessages').html(` 804 $('#chatMessages').html(`
@@ -869,16 +817,13 @@ @@ -869,16 +817,13 @@
869 localStorage.removeItem('chatHistory'); 817 localStorage.removeItem('chatHistory');
870 updateStatus('对话已清空', 'connected'); 818 updateStatus('对话已清空', 'connected');
871 sessionId =""; 819 sessionId ="";
872 - // 确保输入区域在底部  
873 ensureInputAtBottom(); 820 ensureInputAtBottom();
874 } 821 }
875 } 822 }
876 823
877 - // 复制消息  
878 function copyMessage(messageId) { 824 function copyMessage(messageId) {
879 const messageContent = $(`#${messageId}`).find('.message-content').text(); 825 const messageContent = $(`#${messageId}`).find('.message-content').text();
880 navigator.clipboard.writeText(messageContent).then(() => { 826 navigator.clipboard.writeText(messageContent).then(() => {
881 - // 显示复制成功的反馈  
882 const button = $(`#${messageId} .action-btn:first-child`); 827 const button = $(`#${messageId} .action-btn:first-child`);
883 const originalText = button.text(); 828 const originalText = button.text();
884 button.text('已复制'); 829 button.text('已复制');
@@ -888,29 +833,19 @@ @@ -888,29 +833,19 @@
888 }); 833 });
889 } 834 }
890 835
891 - // 重新生成消息  
892 function regenerateMessage(messageId) { 836 function regenerateMessage(messageId) {
893 - // 找到对应的用户消息  
894 const messageDiv = $(`#${messageId}`); 837 const messageDiv = $(`#${messageId}`);
895 const content = messageDiv.find('.message-content').text(); 838 const content = messageDiv.find('.message-content').text();
896 -  
897 - // 从历史中移除  
898 chatHistory = chatHistory.filter(item => 839 chatHistory = chatHistory.filter(item =>
899 item.role !== 'assistant' || item.content !== content 840 item.role !== 'assistant' || item.content !== content
900 ); 841 );
901 -  
902 - // 重新发送  
903 $('#messageInput').val(content); 842 $('#messageInput').val(content);
904 sendMessage(); 843 sendMessage();
905 -  
906 - // 移除旧消息  
907 messageDiv.remove(); 844 messageDiv.remove();
908 } 845 }
909 846
910 - // 错误处理  
911 function handleError(error) { 847 function handleError(error) {
912 hideTypingIndicator(); 848 hideTypingIndicator();
913 -  
914 const errorMessage = ` 849 const errorMessage = `
915 抱歉,请求出现错误:${error.message}<br><br> 850 抱歉,请求出现错误:${error.message}<br><br>
916 <strong>可能的原因:</strong><br> 851 <strong>可能的原因:</strong><br>
@@ -922,14 +857,11 @@ @@ -922,14 +857,11 @@
922 2. 检查浏览器控制台查看详细错误<br> 857 2. 检查浏览器控制台查看详细错误<br>
923 3. 刷新页面重试 858 3. 刷新页面重试
924 `; 859 `;
925 -  
926 addMessage(errorMessage, 'ai'); 860 addMessage(errorMessage, 'ai');
927 updateStatus('请求失败', 'error'); 861 updateStatus('请求失败', 'error');
928 - // 确保输入区域在底部  
929 ensureInputAtBottom(); 862 ensureInputAtBottom();
930 } 863 }
931 864
932 - // 绑定键盘事件  
933 function bindKeyboardEvents() { 865 function bindKeyboardEvents() {
934 $('#messageInput').on('keypress', function(e) { 866 $('#messageInput').on('keypress', function(e) {
935 if (e.which === 13 && !e.shiftKey) { 867 if (e.which === 13 && !e.shiftKey) {
@@ -939,17 +871,12 @@ @@ -939,17 +871,12 @@
939 }); 871 });
940 872
941 $(document).on('keydown', function(e) { 873 $(document).on('keydown', function(e) {
942 - // Ctrl + Enter 发送  
943 if (e.ctrlKey && e.key === 'Enter') { 874 if (e.ctrlKey && e.key === 'Enter') {
944 sendMessage(); 875 sendMessage();
945 } 876 }
946 -  
947 - // ESC 清空输入框  
948 if (e.key === 'Escape') { 877 if (e.key === 'Escape') {
949 $('#messageInput').val(''); 878 $('#messageInput').val('');
950 } 879 }
951 -  
952 - // 上箭头恢复上一条消息  
953 if (e.key === 'ArrowUp' && $('#messageInput').val() === '') { 880 if (e.key === 'ArrowUp' && $('#messageInput').val() === '') {
954 const lastUserMessage = chatHistory 881 const lastUserMessage = chatHistory
955 .filter(item => item.role === 'user') 882 .filter(item => item.role === 'user')
@@ -962,13 +889,11 @@ @@ -962,13 +889,11 @@
962 }); 889 });
963 } 890 }
964 891
965 - // 模型选择器变化  
966 $('#modelSelector').on('change', function() { 892 $('#modelSelector').on('change', function() {
967 currentModel = $(this).val(); 893 currentModel = $(this).val();
968 updateStatus(`切换到${$(this).find('option:selected').text()}模式`, 'connected'); 894 updateStatus(`切换到${$(this).find('option:selected').text()}模式`, 'connected');
969 }); 895 });
970 896
971 - // 监听窗口大小变化,重新计算布局  
972 $(window).on('resize', function() { 897 $(window).on('resize', function() {
973 ensureInputAtBottom(); 898 ensureInputAtBottom();
974 }); 899 });