Merge branch 'main' into function-call-v2

This commit is contained in:
hrz
2025-04-17 11:53:25 +08:00
committed by GitHub
70 changed files with 5671 additions and 695 deletions
+3
View File
@@ -155,6 +155,9 @@ main/manager-web/node_modules
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
main/xiaozhi-server/models/sherpa-onnx*
my_wakeup_words.mp3
!main/xiaozhi-server/config/assets/bind_code.wav
!main/xiaozhi-server/config/assets/bind_not_found.wav
!main/xiaozhi-server/config/assets/bind_code/*.wav
main/manager-api/.vscode
# Ignore webpack cache directory
+1 -1
View File
@@ -160,7 +160,7 @@ server:
```
智控台地址: https://2662r3426b.vicp.fun
OTA接口地址: htts://2662r3426b.vicp.fun/xiaozhi/ota/
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
---
+2 -2
View File
@@ -138,12 +138,12 @@ manager-api:
secret: 你的server.secret值
```
1、把你刚才从`智控台`复制过来的`server.secret``参数值`复制到`.config.yaml`文件里的`secret`里。
2、注意,把`url`改成下面的`http://xiaozhi-esp32-server-web/xiaozhi`
2、注意,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
类似这样的效果
```
manager-api:
url: http://xiaozhi-esp32-server-web/xiaozhi
url: http://xiaozhi-esp32-server-web:8002/xiaozhi
secret: 12345678-xxxx-xxxx-xxxx-123456789000
```
@@ -52,4 +52,7 @@ public interface ErrorCode {
int PARAM_BOOLEAN_INVALID = 10038;
int PARAM_ARRAY_INVALID = 10039;
int PARAM_JSON_INVALID = 10040;
int OTA_DEVICE_NOT_FOUND = 10041;
int OTA_DEVICE_NEED_BIND = 10042;
}
@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
@@ -83,7 +84,12 @@ public class ConfigServiceImpl implements ConfigService {
// 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
throw new RenException("设备未找到");
// 如果设备,去redis里看看有没有需要连接的设备
String cachedCode = deviceService.geCodeByDeviceId(macAddress);
if (StringUtils.isNotBlank(cachedCode)) {
throw new RenException(ErrorCode.OTA_DEVICE_NEED_BIND, cachedCode);
}
throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device");
}
// 获取智能体信息
AgentEntity agent = agentService.getAgentById(device.getAgentId());
@@ -37,23 +37,23 @@ public class OTAController {
@PostMapping
public ResponseEntity<String> checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO,
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
@Parameter(name = "Client-Id", description = "客户端标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Client-Id") String clientId) {
if (StringUtils.isAnyBlank(deviceId, clientId)) {
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
if (StringUtils.isBlank(deviceId)) {
return createResponse(DeviceReportRespDTO.createError("Device ID is required"));
}
if (StringUtils.isBlank(clientId)) {
clientId = deviceId;
}
String macAddress = deviceReportReqDTO.getMacAddress();
boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) {
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request"));
}
return createResponse(deviceService.checkDeviceActive(macAddress, deviceId, clientId, deviceReportReqDTO));
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
}
@Operation(summary = "获取 OTA 提示信息")
@GetMapping
public ResponseEntity<String> getOTAPrompt() {
return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数"));
@@ -19,7 +19,7 @@ public interface DeviceService {
/**
* 检查设备是否激活
*/
DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport);
/**
@@ -74,4 +74,12 @@ public interface DeviceService {
* @return 设备信息
*/
DeviceEntity getDeviceByMacAddress(String macAddress);
/**
* 根据设备ID获取激活码
*
* @param deviceId 设备ID
* @return 激活码
*/
String geCodeByDeviceId(String deviceId);
}
@@ -120,7 +120,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
@Override
public DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
public DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO response = new DeviceReportRespDTO();
response.setServer_time(buildServerTime());
@@ -132,46 +132,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
response.setFirmware(firmware);
DeviceEntity deviceById = getDeviceById(deviceId);
DeviceEntity deviceById = getDeviceById(macAddress);
if (deviceById != null) { // 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date());
deviceDao.updateById(deviceById);
} else { // 如果设备不存在,则生成激活码
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
code.setCode(cachedCode);
code.setMessage(frontedUrl + "\n" + cachedCode);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
code.setMessage(frontedUrl + "\n" + newCode);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId);
dataMap.put("mac_address", macAddress);
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName()
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion()
: null);
dataMap.put("deviceId", deviceId);
dataMap.put("activation_code", newCode);
// 写入主数据 key
redisTemplate.opsForHash().putAll(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
}
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
response.setActivation(code);
}
@@ -258,4 +224,60 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
serverTime.setTimezone_offset(tz.getOffset(System.currentTimeMillis()) / (60 * 1000));
return serverTime;
}
@Override
public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode;
}
return null;
}
private String getDeviceCacheKey(String deviceId) {
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
return dataKey;
}
public DeviceReportRespDTO.Activation buildActivation(String deviceId, DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
String cachedCode = geCodeByDeviceId(deviceId);
if (StringUtils.isNotBlank(cachedCode)) {
code.setCode(cachedCode);
code.setMessage(frontedUrl + "\n" + cachedCode);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
code.setMessage(frontedUrl + "\n" + newCode);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId);
dataMap.put("mac_address", deviceId);
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName()
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion()
: null);
dataMap.put("deviceId", deviceId);
dataMap.put("activation_code", newCode);
// 写入主数据 key
String dataKey = getDeviceCacheKey(deviceId);
redisTemplate.opsForHash().putAll(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
}
return code;
}
}
@@ -121,7 +121,7 @@ public class LoginController {
@Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>();
config.put("version", "0.3.3");
config.put("version", "0.3.5");
config.put("allowUserRegister", sysUserService.getAllowUserRegister());
return new Result<Map<String, Object>>().ok(config);
}
@@ -21,3 +21,8 @@ INSERT INTO `ai_tts_voice` VALUES
delete from `sys_params` where id in (103,104);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (103, 'server.allow_user_register', 'false', 'boolean', 1, '是否运行管理员以外的人注册');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://xiaozhi.server.com', 'string', 1, '下发六位验证码时显示的控制面板地址');
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_CosyVoiceSiliconflow';
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', 'https://example.com/cosyvoice/bella.mp3', NULL, 6, NULL, NULL, NULL, NULL);
@@ -52,9 +52,9 @@ databaseChangeLog:
encoding: utf8
path: classpath:db/changelog/202504112058.sql
- changeSet:
id: 202504151205
id: 202504151206
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504151205.sql
path: classpath:db/changelog/202504151206.sql
@@ -41,3 +41,6 @@
10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false
10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8BBE\u5907\u672A\u627E\u5230
10042={0}
@@ -40,4 +40,7 @@
10037=Parameter value must be a valid number
10038=Parameter value must be true or false
10039=Parameter value must be a valid JSON array format
10040=Parameter value must be a valid JSON format
10040=Parameter value must be a valid JSON format
10041=Device not found
10042={0}
@@ -40,4 +40,7 @@
10037=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684\u6570\u5B57
10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false
10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8BBE\u5907\u672A\u627E\u5230
10042={0}
@@ -40,4 +40,7 @@
10037=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684\u6578\u5B57
10038=\u53C3\u6578\u503C\u5FC5\u9808\u662Ftrue\u6216false
10039=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u6578\u7D44\u683C\u5F0F
10040=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10040=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8A2D\u5099\u672A\u627E\u5230
10042={0}
@@ -33,3 +33,5 @@ timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -32,3 +32,5 @@ timbre.ttsModelId.require=The TTS model ID of the timbre cannot be empty
timbre.ttsVoice.require=The TTS voice of the timbre cannot be empty
timbre.voiceDemo.require=The voice demo of the timbre cannot be empty
ota.device.not.found=Device not found
ota.device.need.bind={0}
@@ -32,3 +32,5 @@ timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u7
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7DE8\u78BC\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653E\u5730\u5740\u4E0D\u53EF\u4EE5\u70BA\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -31,3 +31,6 @@ timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
+3 -1
View File
@@ -1 +1,3 @@
VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_API_BASE_URL=/xiaozhi
# 是否开启CDN
VUE_APP_USE_CDN=false
+3249 -11
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -31,7 +31,8 @@
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14",
"webpack-bundle-analyzer": "^4.10.2"
"webpack-bundle-analyzer": "^4.10.2",
"workbox-webpack-plugin": "^7.3.0"
},
"browserslist": [
"> 1%",
@@ -43,4 +44,4 @@
"*.scss",
"*.vue"
]
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
<title>
<%= process.env.VUE_APP_TITLE %>
</title>
<% if (htmlWebpackPlugin.options.cdn) { %>
<% if (htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.css) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
<% } %>
@@ -23,7 +23,7 @@
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<% if (htmlWebpackPlugin.options.cdn) { %>
<% if (htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %>
+70
View File
@@ -0,0 +1,70 @@
<!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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f7fa;
color: #333;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
max-width: 80%;
}
h1 {
margin-bottom: 1rem;
color: #409EFF;
}
p {
margin-bottom: 1.5rem;
line-height: 1.6;
}
.btn {
background-color: #409EFF;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #337ecc;
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
color: #409EFF;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📶</div>
<h1>您当前处于离线模式</h1>
<p>看起来您的网络连接有问题,无法连接到小智控制台服务器。</p>
<p>部分已缓存的内容和静态资源可能仍然可用,但功能可能受到限制。</p>
<button class="btn" onclick="window.location.reload()">重新尝试连接</button>
</div>
<script>
// 检测网络状态变化
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>
+115
View File
@@ -1,6 +1,7 @@
<template>
<div id="app">
<router-view />
<cache-viewer v-if="isCDNEnabled" :visible.sync="showCacheViewer" />
</div>
</template>
@@ -45,4 +46,118 @@ nav {
}
</style>
<script>
import CacheViewer from '@/components/CacheViewer.vue';
import { logCacheStatus } from '@/utils/cacheViewer';
export default {
components: {
CacheViewer
},
data() {
return {
showCacheViewer: false,
isCDNEnabled: process.env.VUE_APP_USE_CDN === 'true'
};
},
mounted() {
// 只有在启用CDN时才添加相关事件和功能
if (this.isCDNEnabled) {
// 添加全局快捷键Alt+C用于显示缓存查看器
document.addEventListener('keydown', this.handleKeyDown);
// 在全局对象上添加缓存检查方法,便于调试
window.checkCDNCacheStatus = () => {
this.showCacheViewer = true;
};
// 在控制台输出提示信息
console.info(
'%c[小智服务] CDN缓存检查工具已加载',
'color: #409EFF; font-weight: bold;'
);
console.info(
'按下 Alt+C 组合键或在控制台运行 checkCDNCacheStatus() 可以查看CDN缓存状态'
);
// 检查Service Worker状态
this.checkServiceWorkerStatus();
} else {
console.info(
'%c[小智服务] CDN模式已禁用,使用本地打包资源',
'color: #67C23A; font-weight: bold;'
);
}
},
beforeDestroy() {
// 只有在启用CDN时才需要移除事件监听
if (this.isCDNEnabled) {
document.removeEventListener('keydown', this.handleKeyDown);
}
},
methods: {
handleKeyDown(e) {
// Alt+C 快捷键
if (e.altKey && e.key === 'c') {
this.showCacheViewer = true;
}
},
async checkServiceWorkerStatus() {
// 检查Service Worker是否已注册
if ('serviceWorker' in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
console.info(
'%c[小智服务] Service Worker已注册',
'color: #67C23A; font-weight: bold;'
);
// 输出缓存状态到控制台
setTimeout(async () => {
const hasCaches = await logCacheStatus();
if (!hasCaches) {
console.info(
'%c[小智服务] 还未检测到缓存,请刷新页面或等待缓存建立',
'color: #E6A23C; font-weight: bold;'
);
// 开发环境下提供额外提示
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,Service Worker可能无法正常初始化缓存',
'color: #E6A23C; font-weight: bold;'
);
console.info('请尝试以下方法检查Service Worker是否生效:');
console.info('1. 在开发者工具的Application/Application标签页中查看Service Worker状态');
console.info('2. 在开发者工具的Application/Cache/Cache Storage中查看缓存内容');
console.info('3. 使用生产构建(npm run build)并通过HTTP服务器访问以测试完整功能');
}
}
}, 2000);
} else {
console.info(
'%c[小智服务] Service Worker未注册,CDN资源可能无法缓存',
'color: #F56C6C; font-weight: bold;'
);
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,这是正常现象',
'color: #E6A23C; font-weight: bold;'
);
console.info('Service Worker通常只在生产环境中生效');
console.info('要测试Service Worker功能:');
console.info('1. 运行npm run build构建生产版本');
console.info('2. 通过HTTP服务器访问构建后的页面');
}
}
} catch (error) {
console.error('检查Service Worker状态失败:', error);
}
} else {
console.warn('当前浏览器不支持Service WorkerCDN资源缓存功能不可用');
}
}
}
};
</script>
@@ -1,5 +1,5 @@
<template>
<el-dialog :visible.sync="visible" width="400px" center>
<el-dialog :visible="visible" @close="handleClose" width="400px" center>
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -79,7 +79,10 @@ export default {
cancel() {
this.$emit('update:visible', false)
this.deviceCode = ""
}
},
handleClose() {
this.$emit('update:visible', false);
},
}
}
</script>
@@ -44,7 +44,7 @@
</el-select>
</el-form-item>
<el-form-item label="排序号" prop="sortOrder" style="flex: 1;">
<el-input v-model="formData.sort" placeholder="请输入排序号" class="custom-input-bg"></el-input>
<el-input v-model="formData.sort" type="number" placeholder="请输入排序号" class="custom-input-bg"></el-input>
</el-form-item>
</div>
@@ -1,5 +1,5 @@
<template>
<el-dialog :visible.sync="visible" width="400px" center @open="handleOpen">
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -66,7 +66,10 @@ export default {
cancel() {
this.$emit('update:visible', false)
this.wisdomBodyName = ""
}
},
handleClose() {
this.$emit('update:visible', false);
},
}
}
</script>
@@ -0,0 +1,207 @@
<template>
<el-dialog
title="CDN资源缓存状态"
:visible.sync="visible"
width="70%"
:before-close="handleClose"
>
<div v-if="isLoading" class="loading-container">
<p>正在加载缓存信息...</p>
</div>
<div v-else>
<div v-if="!cacheAvailable" class="no-cache-message">
<i class="el-icon-warning-outline"></i>
<p>您的浏览器不支持Cache API或Service Worker未安装</p>
<el-button type="primary" @click="refreshPage">刷新页面</el-button>
</div>
<div v-else>
<el-alert
v-if="cacheData.totalCached === 0"
title="未发现缓存的CDN资源"
type="warning"
:closable="false"
show-icon
>
<p>Service Worker可能尚未完成初始化或缓存尚未建立请刷新页面或等待一会后再试</p>
</el-alert>
<div v-else>
<el-alert
title="CDN资源缓存状态"
type="success"
:closable="false"
show-icon
>
共发现 {{ cacheData.totalCached }} 个缓存资源
</el-alert>
<h3>JavaScript 资源 ({{ cacheData.js.length }})</h3>
<el-table :data="cacheData.js" stripe style="width: 100%">
<el-table-column prop="url" label="URL" width="auto" show-overflow-tooltip />
<el-table-column prop="cached" label="状态" width="100">
<template slot-scope="scope">
<el-tag type="success" v-if="scope.row.cached">已缓存</el-tag>
<el-tag type="danger" v-else>未缓存</el-tag>
</template>
</el-table-column>
</el-table>
<h3>CSS 资源 ({{ cacheData.css.length }})</h3>
<el-table :data="cacheData.css" stripe style="width: 100%">
<el-table-column prop="url" label="URL" width="auto" show-overflow-tooltip />
<el-table-column prop="cached" label="状态" width="100">
<template slot-scope="scope">
<el-tag type="success" v-if="scope.row.cached">已缓存</el-tag>
<el-tag type="danger" v-else>未缓存</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" @click="refreshCache">刷新缓存状态</el-button>
<el-button type="danger" @click="clearCache">清除缓存</el-button>
</span>
</el-dialog>
</template>
<script>
import {
getCacheNames,
checkCdnCacheStatus,
clearAllCaches,
logCacheStatus
} from '../utils/cacheViewer';
export default {
name: 'CacheViewer',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
isLoading: true,
cacheAvailable: false,
cacheData: {
css: [],
js: [],
totalCached: 0,
totalNotCached: 0
}
};
},
watch: {
visible(newVal) {
if (newVal) {
this.loadCacheData();
}
}
},
methods: {
async loadCacheData() {
this.isLoading = true;
try {
// 先检查是否支持缓存API
if (!('caches' in window)) {
this.cacheAvailable = false;
this.isLoading = false;
return;
}
// 检查是否有Service Worker缓存
const cacheNames = await getCacheNames();
this.cacheAvailable = cacheNames.length > 0;
if (this.cacheAvailable) {
// 获取CDN缓存状态
this.cacheData = await checkCdnCacheStatus();
// 在控制台输出完整缓存状态
await logCacheStatus();
}
} catch (error) {
console.error('加载缓存数据失败:', error);
this.$message.error('加载缓存数据失败');
} finally {
this.isLoading = false;
}
},
async refreshCache() {
this.loadCacheData();
this.$message.success('正在刷新缓存状态');
},
async clearCache() {
this.$confirm('确定要清除所有缓存吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const success = await clearAllCaches();
if (success) {
this.$message.success('缓存已清除');
await this.loadCacheData();
} else {
this.$message.error('清除缓存失败');
}
} catch (error) {
console.error('清除缓存失败:', error);
this.$message.error('清除缓存失败');
}
}).catch(() => {
this.$message.info('已取消清除');
});
},
refreshPage() {
window.location.reload();
},
handleClose() {
this.$emit('update:visible', false);
}
}
};
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.loading-spinner {
margin-bottom: 10px;
}
.no-cache-message {
text-align: center;
padding: 20px;
}
.no-cache-message i {
font-size: 48px;
color: #E6A23C;
margin-bottom: 10px;
}
h3 {
margin-top: 20px;
margin-bottom: 10px;
font-weight: 500;
}
</style>
@@ -83,7 +83,8 @@ export default {
font-size: 10px;
color: #5778ff;
background: #e6ebff;
width: 57px;
width: auto;
padding: 0 12px;
height: 21px;
line-height: 21px;
cursor: pointer;
+15 -11
View File
@@ -551,28 +551,26 @@ export default {
/* 备注文本 */
::v-deep .remark-input .el-textarea__inner {
background-color: #f5f5f5;
border-radius: 4px;
border: 1px solid #e6e6e6;
padding: 8px 12px;
resize: none;
max-height: 40px !important;
line-height: 1.5;
}
::v-deep .remark-input .el-textarea__inner::placeholder {
color: black !important;
opacity: 0.7;
}
::v-deep .remark-input .el-textarea__inner {
background-color: #f4f6fa;
background-color: transparent !important;
}
::v-deep .remark-input .el-textarea__inner:focus {
background-color: #edeffb;
border-color: #409EFF !important;
outline: none;
}
::v-deep .remark-input .el-textarea__inner::placeholder {
color: #c0c4cc !important;
opacity: 1;
}
/* 滚动容器 */
.scroll-wrapper {
display: flex;
@@ -650,6 +648,12 @@ export default {
bottom: 20px;
padding-top: 10px;
}
.action-buttons .el-button {
padding: 8px 15px;
font-size: 11px;
}
.edit-btn,
.delete-btn,
.save-btn {
+5
View File
@@ -6,11 +6,16 @@ import App from './App.vue';
import router from './router';
import store from './store';
import './styles/global.scss';
import { register as registerServiceWorker } from './registerServiceWorker';
Vue.use(ElementUI);
Vue.config.productionTip = false
// 注册Service Worker
registerServiceWorker();
// 创建Vue实例
new Vue({
router,
store,
@@ -0,0 +1,113 @@
/* eslint-disable no-console */
export const register = () => {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = `${process.env.BASE_URL}service-worker.js`;
console.info(`[小智服务] 正在尝试注册Service WorkerURL: ${swUrl}`);
// 先检查Service Worker是否已注册
navigator.serviceWorker.getRegistrations().then(registrations => {
if (registrations.length > 0) {
console.info('[小智服务] 发现已有Service Worker注册,正在检查更新');
}
// 继续注册Service Worker
navigator.serviceWorker
.register(swUrl)
.then(registration => {
console.info('[小智服务] Service Worker注册成功');
// 更新处理
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 内容已缓存更新,通知用户刷新
console.log('[小智服务] 新内容可用,请刷新页面');
// 可以在这里展示更新提示
const updateNotification = document.createElement('div');
updateNotification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #409EFF;
color: white;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
z-index: 9999;
`;
updateNotification.innerHTML = `
<div style="display: flex; align-items: center;">
<span style="margin-right: 10px;">发现新版本,点击刷新应用</span>
<button style="background: white; color: #409EFF; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;">刷新</button>
</div>
`;
document.body.appendChild(updateNotification);
updateNotification.querySelector('button').addEventListener('click', () => {
window.location.reload();
});
} else {
// 一切正常,Service Worker已成功安装
console.log('[小智服务] 内容已缓存供离线使用');
// 可以在这里初始化缓存
setTimeout(() => {
// 预热CDN缓存
const cdnUrls = [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css',
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
];
// 预热缓存
cdnUrls.forEach(url => {
fetch(url, { mode: 'no-cors' }).catch(err => {
console.log(`预热缓存 ${url} 失败`, err);
});
});
}, 2000);
}
}
};
};
})
.catch(error => {
console.error('Service Worker 注册失败:', error);
if (error.name === 'TypeError' && error.message.includes('Failed to register a ServiceWorker')) {
console.warn('[小智服务] 注册Service Worker时出现网络错误,CDN资源可能无法缓存');
if (process.env.NODE_ENV === 'production') {
console.info(
'可能原因:1. 服务器未配置正确的MIME类型 2. 服务器SSL证书问题 3. 服务器未返回service-worker.js文件'
);
}
}
});
});
});
}
};
export const unregister = () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
};
+174
View File
@@ -0,0 +1,174 @@
/* global self, workbox */
// 自定义Service Worker安装和激活的处理逻辑
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// CDN资源列表
const CDN_CSS = [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
];
const CDN_JS = [
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
];
// 当Service Worker被注入manifest后会自动执行
const manifest = self.__WB_MANIFEST || [];
// 检查是否启用CDN模式
const isCDNEnabled = manifest.some(entry =>
entry.url === 'cdn-mode' && entry.revision === 'enabled'
);
console.log(`Service Worker 已初始化, CDN模式: ${isCDNEnabled ? '启用' : '禁用'}`);
// 注入workbox相关代码
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
workbox.setConfig({ debug: false });
// 开启workbox
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// 预缓存离线页面
const OFFLINE_URL = '/offline.html';
workbox.precaching.precacheAndRoute([
{ url: OFFLINE_URL, revision: null }
]);
// 添加安装完成事件处理器,在控制台显示安装消息
self.addEventListener('install', event => {
if (isCDNEnabled) {
console.log('Service Worker 已安装,开始缓存CDN资源');
} else {
console.log('Service Worker 已安装,CDN模式禁用,仅缓存本地资源');
}
// 确保离线页面被缓存
event.waitUntil(
caches.open('offline-cache').then((cache) => {
return cache.add(OFFLINE_URL);
})
);
});
// 添加激活事件处理器
self.addEventListener('activate', event => {
console.log('Service Worker 已激活,现在控制着页面');
// 清理旧版本缓存
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
// 清理除当前版本外的缓存
return cacheName.startsWith('workbox-') && !workbox.core.cacheNames.runtime.includes(cacheName);
}).map(cacheName => {
return caches.delete(cacheName);
})
);
})
);
});
// 添加fetch事件拦截器,用于查看CDN资源是否命中缓存
self.addEventListener('fetch', event => {
// 只有启用CDN模式时才进行CDN资源缓存监控
if (isCDNEnabled) {
const url = new URL(event.request.url);
// 针对CDN资源,输出是否命中缓存的信息
if ([...CDN_CSS, ...CDN_JS].includes(url.href)) {
// 不干扰正常的fetch流程,只添加日志
console.log(`请求CDN资源: ${url.href}`);
}
}
});
// 仅在CDN模式下缓存CDN资源
if (isCDNEnabled) {
// 缓存CDN的CSS资源
workbox.routing.registerRoute(
({ url }) => CDN_CSS.includes(url.href),
new workbox.strategies.CacheFirst({
cacheName: 'cdn-stylesheets',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 365 * 24 * 60 * 60, // 增加到1年缓存
maxEntries: 10, // 最多缓存10个CSS文件
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200], // 缓存成功响应
}),
],
})
);
// 缓存CDN的JS资源
workbox.routing.registerRoute(
({ url }) => CDN_JS.includes(url.href),
new workbox.strategies.CacheFirst({
cacheName: 'cdn-scripts',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 365 * 24 * 60 * 60, // 增加到1年缓存
maxEntries: 20, // 最多缓存20个JS文件
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200], // 缓存成功响应
}),
],
})
);
}
// 无论是否启用CDN模式,都缓存本地静态资源
workbox.routing.registerRoute(
/\.(?:js|css|png|jpg|jpeg|svg|gif|ico|woff|woff2|eot|ttf|otf)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天缓存
maxEntries: 50, // 最多缓存50个文件
}),
],
})
);
// 缓存HTML页面
workbox.routing.registerRoute(
/\.html$/,
new workbox.strategies.NetworkFirst({
cacheName: 'html-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 1 * 24 * 60 * 60, // 1天缓存
maxEntries: 10, // 最多缓存10个HTML文件
}),
],
})
);
// 离线页面 - 使用更可靠的处理方式
workbox.routing.setCatchHandler(async ({ event }) => {
// 根据请求类型返回适当的默认页面
switch (event.request.destination) {
case 'document':
// 如果是网页请求,返回离线页面
return caches.match(OFFLINE_URL);
default:
// 所有其他请求返回错误
return Response.error();
}
});
+142
View File
@@ -0,0 +1,142 @@
/**
* 缓存查看工具 - 用于检查CDN资源是否已被Service Worker缓存
*/
/**
* 获取所有Service Worker缓存的名称
* @returns {Promise<string[]>} 缓存名称列表
*/
export const getCacheNames = async () => {
if (!('caches' in window)) {
return [];
}
try {
return await caches.keys();
} catch (error) {
console.error('获取缓存名称失败:', error);
return [];
}
};
/**
* 获取指定缓存中的所有URL
* @param {string} cacheName 缓存名称
* @returns {Promise<string[]>} 缓存的URL列表
*/
export const getCacheUrls = async (cacheName) => {
if (!('caches' in window)) {
return [];
}
try {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
return requests.map(request => request.url);
} catch (error) {
console.error(`获取缓存 ${cacheName} 的URL失败:`, error);
return [];
}
};
/**
* 检查特定URL是否已被缓存
* @param {string} url 要检查的URL
* @returns {Promise<boolean>} 是否已缓存
*/
export const isUrlCached = async (url) => {
if (!('caches' in window)) {
return false;
}
try {
const cacheNames = await getCacheNames();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const match = await cache.match(url);
if (match) {
return true;
}
}
return false;
} catch (error) {
console.error(`检查URL ${url} 是否缓存失败:`, error);
return false;
}
};
/**
* 获取当前页面所有CDN资源的缓存状态
* @returns {Promise<Object>} 缓存状态对象
*/
export const checkCdnCacheStatus = async () => {
// 从CDN缓存中查找资源
const cdnCaches = ['cdn-stylesheets', 'cdn-scripts'];
const results = {
css: [],
js: [],
totalCached: 0,
totalNotCached: 0
};
for (const cacheName of cdnCaches) {
try {
const urls = await getCacheUrls(cacheName);
// 区分CSS和JS资源
for (const url of urls) {
if (url.endsWith('.css')) {
results.css.push({ url, cached: true });
} else if (url.endsWith('.js')) {
results.js.push({ url, cached: true });
}
results.totalCached++;
}
} catch (error) {
console.error(`获取 ${cacheName} 缓存信息失败:`, error);
}
}
return results;
};
/**
* 清除所有Service Worker缓存
* @returns {Promise<boolean>} 是否成功清除
*/
export const clearAllCaches = async () => {
if (!('caches' in window)) {
return false;
}
try {
const cacheNames = await getCacheNames();
for (const cacheName of cacheNames) {
await caches.delete(cacheName);
}
return true;
} catch (error) {
console.error('清除所有缓存失败:', error);
return false;
}
};
/**
* 将缓存状态输出到控制台
*/
export const logCacheStatus = async () => {
console.group('Service Worker 缓存状态');
const cacheNames = await getCacheNames();
console.log('已发现的缓存:', cacheNames);
for (const cacheName of cacheNames) {
const urls = await getCacheUrls(cacheName);
console.group(`缓存: ${cacheName} (${urls.length} 项)`);
urls.forEach(url => console.log(url));
console.groupEnd();
}
console.groupEnd();
return cacheNames.length > 0;
};
+157 -27
View File
@@ -14,14 +14,13 @@
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-card class="device-card" shadow="never">
<el-table
ref="deviceTable"
:data="paginatedDeviceList"
@selection-change="handleSelectionChange"
class="transparent-table"
:header-cell-class-name="headerCellClassName"
stripe>
:header-cell-class-name="headerCellClassName">
<el-table-column type="selection" align="center" width="120"></el-table-column>
<el-table-column label="设备型号" prop="model" align="center"></el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" align="center" ></el-table-column>
@@ -67,6 +66,14 @@
@click="deleteSelected">解绑</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button
@@ -108,7 +115,8 @@ export default {
activeSearchKeyword: "",
currentAgentId: this.$route.query.agentId || '',
currentPage: 1,
pageSize: 5,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
deviceList: [],
loading: false,
userApi: null,
@@ -132,7 +140,6 @@ export default {
},
pageCount() {
return Math.ceil(this.filteredDeviceList.length / this.pageSize);
},
visiblePages() {
const pages = [];
@@ -148,7 +155,7 @@ export default {
pages.push(i);
}
return pages;
}
},
},
mounted() {
const agentId = this.$route.query.agentId;
@@ -157,6 +164,10 @@ export default {
}
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
},
handleSearch() {
this.activeSearchKeyword = this.searchKeyword;
this.currentPage = 1;
@@ -272,13 +283,18 @@ export default {
this.deviceList = data.data.map(device => {
const bindDate = new Date(device.createDate);
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
let formattedLastConversation = '';
if (device.lastConnectedAt) {
const lastConvoDate = new Date(device.lastConnectedAt);
formattedLastConversation = `${lastConvoDate.getFullYear()}-${(lastConvoDate.getMonth() + 1).toString().padStart(2, '0')}-${lastConvoDate.getDate().toString().padStart(2, '0')} ${lastConvoDate.getHours().toString().padStart(2, '0')}:${lastConvoDate.getMinutes().toString().padStart(2, '0')}:${lastConvoDate.getSeconds().toString().padStart(2, '0')}`;
}
return {
device_id: device.id,
model: device.board,
firmwareVersion: device.appVersion,
macAddress: device.macAddress,
bindTime: formattedBindTime,
lastConversation: device.lastConnectedAt,
lastConversation: formattedLastConversation,
remark: device.alias,
isEdit: false,
otaSwitch: device.autoUpdate === 1,
@@ -320,10 +336,14 @@ export default {
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: 600px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
@@ -356,6 +376,60 @@ export default {
color: white;
}
::v-deep .search-input .el-input__inner {
border-radius: 4px;
border: 1px solid #DCDFE6;
background-color: white;
transition: border-color 0.2s;
}
::v-deep .page-size-select{
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner{
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
::v-deep .page-size-select .el-input__suffix{
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner{
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .el-icon-arrow-up:before{
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
::v-deep .search-input .el-input__inner:focus {
border-color: #6b8cff;
outline: none;
}
.content-panel {
flex: 1;
display: flex;
@@ -370,28 +444,34 @@ export default {
flex: 1;
height: 100%;
min-width: 600px;
overflow-x: auto;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.params-card {
.device-card {
background: white;
border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
padding: 0 20px;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 6px;
padding-left: 26px;
}
.ctrl_btn .el-button {
@@ -430,12 +510,18 @@ export default {
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
gap: 10px;
}
.pagination-btn {
min-width: 28px;
.custom-pagination .el-select {
margin-right: 8px;
}
.custom-pagination .pagination-btn:first-child,
.custom-pagination .pagination-btn:nth-child(2),
.custom-pagination .pagination-btn:nth-last-child(2),
.custom-pagination .pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
@@ -447,28 +533,46 @@ export default {
transition: all 0.3s ease;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2) {
min-width: 60px;
}
.pagination-btn:hover {
.custom-pagination .pagination-btn:first-child:hover,
.custom-pagination .pagination-btn:nth-child(2):hover,
.custom-pagination .pagination-btn:nth-last-child(2):hover,
.custom-pagination .pagination-btn:nth-child(3):hover {
background: #d7dce6;
}
.pagination-btn:disabled {
.custom-pagination .pagination-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pagination-btn.active {
.custom-pagination .pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.custom-pagination .pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)):hover {
background: rgba(245, 247, 250, 0.3);
}
.custom-pagination .pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
}
.total-text {
.custom-pagination .pagination-btn.active:hover {
background: #6d7cf5 !important;
}
.custom-pagination .total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
@@ -525,5 +629,31 @@ export default {
color: #5a64b5;
}
:deep(.transparent-table) {
flex: 1;
display: flex;
flex-direction: column;
max-height: calc(100vh - 40vh);
}
:deep(.el-table__body-wrapper) {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
:deep(.el-table__header-wrapper) {
flex-shrink: 0;
}
@media (min-width: 1144px) {
.table_bottom {
margin-top: 40px;
}
:deep(.transparent-table) .el-table__body tr td {
padding-top: 16px;
padding-bottom: 16px;
}
}
</style>
+132 -104
View File
@@ -43,49 +43,49 @@
<!-- 右侧内容 -->
<div class="content-area">
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }"
:data="modelList" class="data-table" header-row-class-name="table-header"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
<el-table-column label="提供商" align="center">
<template slot-scope="scope">
{{ scope.row.configJson.type || '未知' }}
</template>
</el-table-column>
<el-table-column label="是否启用" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="是否默认" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleDefaultChange(scope.row)" />
</template>
</el-table-column>
<el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn">
音色管理
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150px">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
修改
</el-button>
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-card class="model-card" shadow="never">
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }"
:data="modelList" class="data-table" header-row-class-name="table-header"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
<el-table-column label="提供商" align="center">
<template slot-scope="scope">
{{ scope.row.configJson.type || '未知' }}
</template>
</el-table-column>
<el-table-column label="是否启用" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="是否默认" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleDefaultChange(scope.row)" />
</template>
</el-table-column>
<el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn">
音色管理
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150px">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
修改
</el-button>
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAll">
{{ isAllSelected ?
@@ -121,6 +121,7 @@
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
@@ -151,9 +152,9 @@ export default {
ttsDialogVisible: false,
selectedTtsModelId: '',
modelList: [],
pageSizeOptions: [5, 10, 20, 50, 100],
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 5,
pageSize: 10,
total: 0,
selectedModels: [],
isAllSelected: false
@@ -165,7 +166,6 @@ export default {
},
computed: {
modelTypeText() {
const map = {
vad: '语言活动检测模型(VAD)',
@@ -177,8 +177,6 @@ export default {
}
return map[this.activeTab] || '模型配置'
},
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
@@ -200,6 +198,11 @@ export default {
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.loadData();
},
openTtsDialog(row) {
this.selectedTtsModelId = row.id;
this.ttsDialogVisible = true;
@@ -212,7 +215,8 @@ export default {
},
handleMenuSelect(index) {
this.activeTab = index;
this.currentPage = 1;
this.currentPage = 1; // 重置到第一页
this.pageSize = 10; // 可选:重置每页条数
this.loadData();
},
handleSearch() {
@@ -457,7 +461,9 @@ export default {
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: 600px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -475,12 +481,6 @@ export default {
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.content-panel {
flex: 1;
display: flex;
@@ -553,17 +553,10 @@ export default {
padding: 24px;
height: 100%;
min-width: 600px;
overflow-x: auto;
overflow: hidden;
background-color: white;
}
.title-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: nowrap;
flex-direction: column;
}
.action-group {
@@ -587,6 +580,11 @@ export default {
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
::v-deep .search-input .el-input__inner {
border-radius: 4px;
border: 1px solid #DCDFE6;
@@ -594,22 +592,53 @@ export default {
transition: border-color 0.2s;
}
::v-deep .page-size-select{
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner{
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
::v-deep .page-size-select .el-input__suffix{
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner{
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .el-icon-arrow-up:before{
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
::v-deep .search-input .el-input__inner:focus {
border-color: #6b8cff;
outline: none;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.data-table {
border-radius: 6px;
overflow: hidden;
@@ -627,12 +656,14 @@ export default {
}
.table-footer {
margin-top: 24px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
width: 100%;
flex-shrink: 0;
min-height: 60px;
background: white;
}
.batch-actions {
@@ -640,12 +671,6 @@ export default {
gap: 8px;
}
.title-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.batch-actions .el-button {
min-width: 72px;
height: 32px;
@@ -697,11 +722,6 @@ export default {
background-color: transparent !important;
}
.pagination-container {
display: flex;
justify-content: flex-end;
}
::v-deep .el-table .custom-selection-header .cell .el-checkbox__inner {
display: none !important;
}
@@ -802,7 +822,6 @@ export default {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
/* 导航按钮样式 (首页、上一页、下一页) */
.pagination-btn:first-child,
@@ -865,23 +884,32 @@ export default {
}
}
.page-size-select {
width: 100px;
margin-right: 8px;
.model-card{
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
}
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
.model-card ::v-deep .el-card__body{
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
:deep(.el-input__suffix) {
line-height: 32px;
}
.data-table {
--table-max-height: calc(100vh - 45vh);
max-height: var(--table-max-height);
}
.data-table ::v-deep .el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 80px);
overflow-y: auto;
}
</style>
+60 -32
View File
@@ -17,7 +17,11 @@
<el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
<el-table-column label="参数值" prop="paramValue" align="center"
show-overflow-tooltip></el-table-column>
@@ -37,7 +41,7 @@
</el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteParam($refs.paramsTable.selection)">删除</el-button>
@click="deleteSelectedParams">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
@@ -88,8 +92,8 @@ export default {
searchCode: "",
paramsList: [],
currentPage: 1,
pageSize: 5,
pageSizeOptions: [5, 10, 20, 50, 100],
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增参数",
@@ -142,12 +146,15 @@ export default {
},
({ data }) => {
if (data.code === 0) {
this.paramsList = data.data.list;
this.paramsList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total;
} else {
this.$message.error({
message:data.msg || '获取参数列表失败',
showClose:true
message: data.msg || '获取参数列表失败',
showClose: true
});
}
}
@@ -158,12 +165,10 @@ export default {
this.fetchParams();
},
handleSelectAll() {
if (this.isAllSelected) {
this.$refs.paramsTable.clearSelection();
} else {
this.$refs.paramsTable.toggleAllSelection();
}
this.isAllSelected = !this.isAllSelected;
this.paramsList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增参数";
@@ -207,6 +212,18 @@ export default {
});
}
},
deleteSelectedParams() {
const selectedRows = this.paramsList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
this.deleteParam(selectedRows);
},
deleteParam(row) {
// 处理单个参数或参数数组
const params = Array.isArray(row) ? row : [row];
@@ -313,7 +330,7 @@ export default {
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 350px);
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -426,8 +443,7 @@ export default {
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
gap: 10px;
.el-select {
margin-right: 8px;
@@ -500,7 +516,7 @@ export default {
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow: auto;
overflow-y: auto;
max-height: none !important;
}
@@ -526,19 +542,6 @@ export default {
}
}
:deep(.custom-selection-header) {
.el-checkbox {
display: none !important;
}
&::after {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
@@ -597,7 +600,7 @@ export default {
.page-size-select {
width: 100px;
margin-right: 8px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
@@ -610,7 +613,32 @@ export default {
}
:deep(.el-input__suffix) {
line-height: 32px;
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
@@ -621,7 +649,7 @@ export default {
}
.el-table {
--table-max-height: calc(100vh - 400px);
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
+103 -73
View File
@@ -15,9 +15,12 @@
<div class="content-panel">
<div class="content-area">
<el-card class="user-card" shadow="never">
<el-table ref="userTable" :data="userList" class="transparent-table"
:header-cell-class-name="headerCellClassName" :max-height="tableMaxHeight">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
<el-table ref="userTable" :data="userList" class="transparent-table">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
@@ -50,7 +53,6 @@
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option
v-for="item in pageSizeOptions"
@@ -97,11 +99,10 @@ export default {
currentPassword: "",
searchPhone: "",
userList: [],
pageSizeOptions: [5, 10, 20, 50, 100],
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 5,
pageSize: 10,
total: 0,
tableMaxHeight: 410,
isAllSelected: false
};
},
@@ -136,34 +137,35 @@ export default {
},
fetchUsers() {
Api.admin.getUserList(
{
page: this.currentPage,
limit: this.pageSize,
mobile: this.searchPhone,
},
({ data }) => {
if (data.code === 0) {
this.userList = data.data.list
this.total = data.data.total;
}
}
);
Api.admin.getUserList(
{
page: this.currentPage,
limit: this.pageSize,
mobile: this.searchPhone,
},
({ data }) => {
if (data.code === 0) {
this.userList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total;
}
}
);
},
handleSearch() {
this.currentPage = 1;
this.fetchUsers();
},
handleSelectAll() {
if (this.isAllSelected) {
this.$refs.userTable.clearSelection();
} else {
this.$refs.userTable.toggleAllSelection();
}
this.isAllSelected = !this.isAllSelected;
this.isAllSelected = !this.isAllSelected;
this.userList.forEach(row => {
row.selected = this.isAllSelected;
});
},
batchDelete() {
const selectedUsers = this.$refs.userTable.selection;
const selectedUsers = this.userList.filter(user => user.selected);
if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要删除的用户");
return;
@@ -228,20 +230,12 @@ export default {
});
},
batchEnable() {
const selectedUsers = this.$refs.userTable.selection;
if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要启用的用户");
return;
}
this.handleChangeStatus(selectedUsers, 1);
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 1);
},
batchDisable() {
const selectedUsers = this.$refs.userTable.selection;
if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要禁用的用户");
return;
}
this.handleChangeStatus(selectedUsers, 0);
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 0);
},
resetPassword(row) {
this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
@@ -284,12 +278,6 @@ export default {
})
.catch(() => { });
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchUsers();
@@ -361,13 +349,15 @@ export default {
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 350px);
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -419,12 +409,18 @@ export default {
min-width: 600px;
overflow-x: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.user-card {
background: white;
border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.table_bottom {
@@ -489,7 +485,6 @@ export default {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
.el-select {
margin-right: 8px;
@@ -556,6 +551,19 @@ export default {
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
@@ -584,19 +592,6 @@ export default {
color: #5a64b5 !important;
}
:deep(.custom-selection-header) {
.el-checkbox {
display: none !important;
}
&::after {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
@@ -635,22 +630,57 @@ export default {
}
.page-size-select {
width: 100px;
margin-right: 8px;
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
line-height: 32px;
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="welcome">
<div class="welcome" @keyup.enter="register">
<el-container style="height: 100%;">
<!-- 保持相同的头部 -->
<el-header>
+279 -162
View File
@@ -1,88 +1,126 @@
<template>
<div class="welcome">
<HeaderBar />
<el-main style="padding: 16px;display: flex;flex-direction: column;">
<div style="border-radius: 16px;background: #fafcfe; border: 1px solid #e8f0ff;">
<div
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
<div
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;" />
</div>
{{ form.agentName }}
</div>
<div style="height: 1px;background: #e8f0ff;" />
<el-form ref="form" :model="form" label-width="72px">
<div style="padding: 16px 24px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
<div>
<el-form-item label="助手昵称:">
<div class="input-46" style="width: 100%;">
<el-input v-model="form.agentName" />
</div>
</el-form-item>
<el-form-item label="角色模版:">
<div style="display: flex;gap: 8px;flex-wrap: wrap;">
<div v-for="(template, index) in templates" :key="`template-${index}`" class="template-item"
:class="{ 'template-loading': loadingTemplate }" @click="selectTemplate(template)">
{{ template.agentName }}
</div>
</div>
</el-form-item>
<el-form-item label="角色介绍:">
<div class="textarea-box">
<el-input type="textarea" rows="5" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
maxlength="2000" show-word-limit />
</div>
</el-form-item>
<el-form-item label="语言编码:">
<div class="input-46" style="width: 100%;">
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit />
</div>
</el-form-item>
<el-form-item label="交互语种:">
<div class="input-46" style="width: 100%;">
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit />
</div>
</el-form-item>
</div>
<div>
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
class="model-item">
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="select-field">
<el-option v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="角色音色:">
<div style="display: flex;gap: 8px;align-items: center;">
<div class="input-46" style="width: 100%;">
<el-select v-model="form.ttsVoiceId" placeholder="请选择" style="width: 100%;">
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</el-form-item>
<HeaderBar/>
<div class="operation-bar">
<h2 class="page-title">角色配置</h2>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="config-card" shadow="never">
<div class="config-header">
<div class="header-icon">
<img loading="lazy" src="@/assets/home/setting-user.png" alt="">
</div>
<span class="header-title">{{ form.agentName }}</span>
</div>
</div>
</el-form>
<div style="display: flex;padding: 16px;gap: 8px;align-items: center;">
<div class="save-btn" @click="saveConfig">
保存配置
</div>
<div class="reset-btn" @click="resetConfig">
重制
</div>
<div class="clear-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;" />
保存配置后需要重启设备新的配置才会生效
</div>
<div class="divider"></div>
<el-form ref="form" :model="form" label-width="72px">
<div class="form-content">
<div class="form-grid">
<div class="form-column">
<el-form-item label="助手昵称:">
<el-input v-model="form.agentName" class="form-input"/>
</el-form-item>
<el-form-item label="角色模版:">
<div class="template-container">
<div
v-for="(template, index) in templates"
:key="`template-${index}`"
class="template-item"
:class="{ 'template-loading': loadingTemplate }"
@click="selectTemplate(template)"
>
{{ template.agentName }}
</div>
</div>
</el-form-item>
<el-form-item label="角色介绍:">
<el-input
type="textarea"
rows="5"
resize="none"
placeholder="请输入内容"
v-model="form.systemPrompt"
maxlength="2000"
show-word-limit
class="form-textarea"
/>
</el-form-item>
<el-form-item label="语言编码:">
<el-input
v-model="form.langCode"
placeholder="请输入语言编码,如:zh_CN"
maxlength="10"
show-word-limit
class="form-input"
/>
</el-form-item>
<el-form-item label="交互语种:">
<el-input
v-model="form.language"
placeholder="请输入交互语种,如:中文"
maxlength="10"
show-word-limit
class="form-input"
/>
</el-form-item>
<div class="action-bar">
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
</div>
</div>
<div class="form-column">
<el-form-item
v-for="(model, index) in models"
:key="`model-${index}`"
:label="model.label"
class="model-item"
>
<el-select
v-model="form.model[model.key]"
filterable
placeholder="请选择"
class="form-select"
>
<el-option
v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色音色:">
<el-select
v-model="form.ttsVoiceId"
placeholder="请选择"
class="form-select"
>
<el-option
v-for="(item, index) in voiceOptions"
:key="`voice-${index}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
</div>
</div>
</el-form>
</el-card>
</div>
</div>
</el-main>
</div>
</div>
</template>
@@ -90,10 +128,9 @@
import Api from '@/apis/api';
import HeaderBar from "@/components/HeaderBar.vue";
export default {
name: 'RoleConfigPage',
components: { HeaderBar },
components: {HeaderBar},
data() {
return {
form: {
@@ -114,12 +151,12 @@ export default {
}
},
models: [
{ label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD' },
{ label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR' },
{ label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM' },
{ label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent' },
{ label: '记忆(Memory)', key: 'memModelId', type: 'Memory' },
{ label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS' },
{label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD'},
{label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR'},
{label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM'},
{label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent'},
{label: '记忆(Memory)', key: 'memModelId', type: 'Memory'},
{label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS'},
],
modelOptions: {},
templates: [],
@@ -144,7 +181,7 @@ export default {
language: this.form.language,
sort: this.form.sort
};
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({data}) => {
if (data.code === 0) {
this.$message.success({
message: '配置保存成功',
@@ -164,7 +201,6 @@ export default {
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 重置表单
this.form = {
agentCode: "",
agentName: "",
@@ -187,10 +223,10 @@ export default {
showClose: true
})
}).catch(() => {
})
});
},
fetchTemplates() {
Api.agent.getAgentTemplate(({ data }) => {
Api.agent.getAgentTemplate(({data}) => {
if (data.code === 0) {
this.templates = data.data;
} else {
@@ -235,7 +271,7 @@ export default {
};
},
fetchAgentConfig(agentId) {
Api.agent.getDeviceConfig(agentId, ({ data }) => {
Api.agent.getDeviceConfig(agentId, ({data}) => {
if (data.code === 0) {
this.form = {
...this.form,
@@ -255,9 +291,8 @@ export default {
});
},
fetchModelOptions() {
// 为每个模型类型获取选项
this.models.forEach(model => {
Api.model.getModelNames(model.type, '', ({ data }) => {
Api.model.getModelNames(model.type, '', ({data}) => {
if (data.code === 0) {
this.$set(this.modelOptions, model.type, data.data.map(item => ({
value: item.id,
@@ -274,7 +309,7 @@ export default {
this.voiceOptions = [];
return;
}
Api.model.getModelVoices(modelId, '', ({ data }) => {
Api.model.getModelVoices(modelId, '', ({data}) => {
if (data.code === 0 && data.data) {
this.voiceOptions = data.data.map(voice => ({
value: voice.id,
@@ -289,7 +324,6 @@ export default {
watch: {
'form.model.ttsModelId': {
handler(newVal, oldVal) {
console.log('TTS模型变化:', newVal);
if (oldVal && newVal !== oldVal) {
this.form.ttsVoiceId = '';
this.fetchVoiceOptions(newVal);
@@ -325,60 +359,131 @@ export default {
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background: linear-gradient(145deg, #e6eeff, #eff0ff);
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
background-size: cover;
/* 确保背景图像覆盖整个元素 */
background-position: center;
/* 从顶部中心对齐 */
-webkit-background-size: cover;
/* 兼容老版本WebKit浏览器 */
-o-background-size: cover;
/* 兼容老版本Opera浏览器 */
overflow: hidden;
}
.el-form-item ::v-deep .el-form-item__label {
font-size: 10px !important;
color: #3d4566 !important;
font-weight: 400;
line-height: 22px;
padding-bottom: 2px;
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.select-field {
width: 100%;
max-width: 720px;
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 8px;
height: 36px !important;
.page-title {
font-size: 24px;
margin: 0;
color: #2c3e50;
}
.audio-box {
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.content-panel {
flex: 1;
height: 37px;
border-radius: 20px;
border: 1px solid #e4e6ef;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.clear-btn {
width: 48px;
height: 19px;
background: #fd8383;
border-radius: 10px;
line-height: 19px;
font-size: 11px;
color: #fff;
cursor: pointer;
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.clear-text {
color: #979db1;
font-size: 11px;
.config-card {
background: white;
border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
.config-header {
display: flex;
align-items: center;
gap: 13px;
padding: 0 0 5px 0;
font-weight: 700;
font-size: 19px;
color: #3d4566;
}
.header-icon {
width: 37px;
height: 37px;
background: #5778ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.header-icon img {
width: 19px;
height: 19px;
}
.divider {
height: 1px;
background: #e8f0ff;
}
.form-content {
padding: 20px 0;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-column {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-input {
width: 100%;
}
.form-select {
width: 100%;
}
.form-textarea {
width: 100%;
}
.template-container {
display: flex;
gap: 8px;
margin-left: 16px;
flex-wrap: wrap;
}
.template-item {
@@ -399,47 +504,59 @@ export default {
background-color: #d0d8ff;
}
.prompt-bottom {
margin-bottom: 4px;
.action-bar {
display: flex;
justify-content: space-between;
padding: 0 16px;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
align-items: center;
}
.input-46 {
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 8px;
height: 36px !important;
}
.save-btn,
.reset-btn {
width: 112px;
height: 37px;
border-radius: 18px;
line-height: 37px;
box-sizing: border-box;
cursor: pointer;
font-size: 11px
}
.save-btn {
border-radius: 18px;
background: #5778ff;
color: #fff;
color: white;
border: none;
border-radius: 18px;
padding: 10px 20px;
}
.reset-btn {
border: 1px solid #adbdff;
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
}
.textarea-box {
border: 1px solid #e4e6ef;
border-radius: 8px;
background: #f6f8fb;
.hint-text {
display: flex;
align-items: center;
gap: 8px;
color: #979db1;
font-size: 11px;
margin-left: 16px;
}
</style>
.hint-text img {
width: 19px;
height: 19px;
}
::v-deep .el-form-item__label {
font-size: 10px !important;
color: #3d4566 !important;
font-weight: 400;
line-height: 22px;
padding-bottom: 2px;
}
::v-deep .el-textarea .el-input__count {
color: #909399;
background: none;
position: absolute;
font-size: 12px;
bottom: -10px;
right: 21px;
}
</style>
+71 -31
View File
@@ -6,10 +6,38 @@ const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin')
// BundleAnalyzerPlugin 用于分析打包后的文件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// WorkboxPlugin 用于生成Service Worker
const { InjectManifest } = require('workbox-webpack-plugin');
// 引入 path 模块
const path = require('path');
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
// 确保加载 .env 文件
dotenv.config();
// 定义CDN资源列表,确保Service Worker也能访问
const cdnResources = {
css: [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
js: [
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
]
};
// 判断是否使用CDN
const useCDN = process.env.VUE_APP_USE_CDN === 'true';
module.exports = defineConfig({
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
devServer: {
@@ -29,21 +57,9 @@ module.exports = defineConfig({
// 修改 HTML 插件配置,动态插入 CDN 链接
config.plugin('html')
.tap(args => {
if (process.env.NODE_ENV === 'production') {
args[0].cdn = {
css: [
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
js: [
'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js',
'https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js',
'https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js',
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js',
'https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js',
'https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/dist/opus-decoder.min.js'
]
};
// 根据配置决定是否使用CDN
if (process.env.NODE_ENV === 'production' && useCDN) {
args[0].cdn = cdnResources;
}
return args;
});
@@ -101,14 +117,38 @@ module.exports = defineConfig({
minRatio: 0.8
})
);
config.externals = {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'element-ui': 'ELEMENT',
'axios': 'axios',
'opus-decoder': 'OpusDecoder'
};
// 根据是否使用CDN来决定是否添加Service Worker
config.plugins.push(
new InjectManifest({
swSrc: path.resolve(__dirname, 'src/service-worker.js'),
swDest: 'service-worker.js',
exclude: [/\.map$/, /asset-manifest\.json$/],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
// 自定义Service Worker注入点
injectionPoint: 'self.__WB_MANIFEST',
// 添加额外信息传递给Service Worker
additionalManifestEntries: useCDN ?
[{ url: 'cdn-mode', revision: 'enabled' }] :
[{ url: 'cdn-mode', revision: 'disabled' }]
})
);
// 如果使用CDN,则配置externals排除依赖包
if (useCDN) {
config.externals = {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'element-ui': 'ELEMENT',
'axios': 'axios',
'opus-decoder': 'OpusDecoder'
};
} else {
// 确保不使用CDN时不设置externals,让webpack打包所有依赖
config.externals = {};
}
if (process.env.ANALYZE === 'true') { // 通过环境变量控制
config.plugins.push(
new BundleAnalyzerPlugin({
@@ -128,13 +168,13 @@ module.exports = defineConfig({
config: [__filename] // 每次配置文件修改时缓存失效
}
};
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'), // 让 '@' 代表 'src' 目录
'@assets': path.resolve(__dirname, 'src/assets'), // 设置 '@assets' 为 'src/assets' 目录
'@components': path.resolve(__dirname, 'src/components'), // 设置 '@components' 为 'src/components' 目录
'@views': path.resolve(__dirname, 'src/views'), // 设置 '@views' 为 'src/views' 目录
}
}
},
// 将CDN资源信息暴露给service-worker.js
pwa: {
workboxOptions: {
skipWaiting: true,
clientsClaim: true
}
}
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16 -1
View File
@@ -4,6 +4,17 @@ import requests
import yaml
import time
class DeviceNotFoundException(Exception):
pass
class DeviceBindException(Exception):
def __init__(self, bind_code):
self.bind_code = bind_code
super().__init__(f"设备绑定异常,绑定码: {bind_code}")
# 添加全局配置缓存
_config_cache = None
@@ -83,7 +94,11 @@ def _make_api_request(api_url, secret, endpoint, json_data=None):
response = requests.post(f"{api_url}{endpoint}", json=json_data)
if response.status_code == 200:
result = response.json()
if result.get("code") != 0:
if result.get("code") == 10041:
raise DeviceNotFoundException(result.get("msg"))
elif result.get("code") == 10042:
raise DeviceBindException(result.get("msg"))
elif result.get("code") != 0:
raise Exception(f"API返回错误: {result.get('msg', '未知错误')}")
return result.get("data")
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from loguru import logger
from config.config_loader import load_config
SERVER_VERSION = "0.3.3"
SERVER_VERSION = "0.3.5"
def get_module_abbreviation(module_name, module_dict):
+43 -8
View File
@@ -27,7 +27,11 @@ from core.handle.functionHandler import FunctionHandler
from plugins_func.register import Action, ActionResponse
from core.auth import AuthMiddleware, AuthenticationError
from core.mcp.manager import MCPManager
from config.config_loader import get_private_config_from_api
from config.config_loader import (
get_private_config_from_api,
DeviceNotFoundException,
DeviceBindException,
)
TAG = __name__
@@ -46,6 +50,9 @@ class ConnectionHandler:
self.logger = setup_logging()
self.auth = AuthMiddleware(config)
self.need_bind = False
self.bind_code = None
self.websocket = None
self.headers = None
self.client_ip = None
@@ -109,6 +116,27 @@ class ConnectionHandler:
try:
# 获取并验证headers
self.headers = dict(ws.request.headers)
if self.headers.get("device-id", None) is None:
# 尝试从 URL 的查询参数中获取 device-id
from urllib.parse import parse_qs, urlparse
# 从 WebSocket 请求中获取路径
request_path = ws.request.path
if not request_path:
self.logger.bind(tag=TAG).error("无法获取请求路径")
return
parsed_url = urlparse(request_path)
query_params = parse_qs(parsed_url.query)
if "device-id" in query_params:
self.headers["device-id"] = query_params["device-id"][0]
self.headers["client-id"] = query_params["client-id"][0]
else:
self.logger.bind(tag=TAG).error(
"无法从请求头和URL查询参数中获取device-id"
)
return
# 获取客户端ip地址
self.client_ip = ws.remote_address[0]
self.logger.bind(tag=TAG).info(
@@ -177,7 +205,6 @@ class ConnectionHandler:
self._initialize_models()
"""加载提示词"""
self.prompt = self.config["prompt"]
self.dialogue.put(Message(role="system", content=self.prompt))
"""加载记忆"""
@@ -206,7 +233,15 @@ class ConnectionHandler:
)
private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info(f"获取差异化配置成功: {private_config}")
except DeviceNotFoundException as e:
self.need_bind = True
private_config = {}
except DeviceBindException as e:
self.need_bind = True
self.bind_code = e.bind_code
private_config = {}
except Exception as e:
self.need_bind = True
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
private_config = {}
@@ -254,8 +289,6 @@ class ConnectionHandler:
self.config["selected_module"]["Intent"] = private_config[
"selected_module"
]["Intent"]
if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"]
try:
modules = initialize_modules(
self.logger,
@@ -270,6 +303,10 @@ class ConnectionHandler:
except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {}
if modules.get("vad", None) is not None:
self.vad = modules["vad"]
if modules.get("asr", None) is not None:
self.asr = modules["asr"]
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("llm", None) is not None:
@@ -278,6 +315,8 @@ class ConnectionHandler:
self.intent = modules["intent"]
if modules.get("memory", None) is not None:
self.memory = modules["memory"]
if modules.get("prompt", None) is not None:
self.change_system_prompt(modules["prompt"])
def _initialize_memory(self):
"""初始化记忆模块"""
@@ -345,7 +384,6 @@ class ConnectionHandler:
response_message = []
processed_chars = 0 # 跟踪已处理的字符位置
try:
start_time = time.time()
# 使用带记忆的对话
future = asyncio.run_coroutine_threadsafe(
self.memory.query_memory(query), self.loop
@@ -367,9 +405,6 @@ class ConnectionHandler:
if self.client_abort:
break
end_time = time.time()
# self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
# 合并当前全部文本并处理未分割部分
full_text = "".join(response_message)
current_text = full_text[processed_chars:] # 从未处理的位置开始
@@ -81,6 +81,8 @@ async def wakeupWordsResponse(conn):
"""唤醒词响应"""
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word)
if result is None or result == "":
return
tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
if tts_file is not None and os.path.exists(tts_file):
@@ -1,5 +1,6 @@
from config.logger import setup_logging
import time
import asyncio
from core.utils.util import remove_punctuation_and_length
from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent
@@ -35,9 +36,7 @@ async def handleAudioMessage(conn, audio):
if len(conn.asr_audio) < 15:
conn.asr_server_receive = True
else:
text, file_path = await conn.asr.speech_to_text(
conn.asr_audio, conn.session_id
)
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
logger.bind(tag=TAG).info(f"识别文本: {text}")
text_len, _ = remove_punctuation_and_length(text)
if text_len > 0:
@@ -49,6 +48,9 @@ async def handleAudioMessage(conn, audio):
async def startToChat(conn, text):
if conn.need_bind:
await check_bind_device(conn)
return
# 首先进行意图分析
intent_handled = await handle_user_intent(conn, text)
@@ -85,3 +87,44 @@ async def no_voice_close_connect(conn):
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
)
await startToChat(conn, prompt)
async def check_bind_device(conn):
if conn.bind_code:
# 确保bind_code是6位数字
if len(conn.bind_code) != 6:
logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
text = "绑定码格式错误,请检查配置。"
await send_stt_message(conn, text)
return
text = f"请登录控制面板,输入{conn.bind_code},绑定设备。"
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 6
conn.llm_finish_task = True
# 播放提示音
music_path = "config/assets/bind_code.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0))
# 逐个播放数字
for i in range(6): # 确保只播放6位数字
try:
digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav"
num_packets, _ = conn.tts.audio_to_opus_data(num_path)
conn.audio_play_queue.put((num_packets, None, i + 1))
except Exception as e:
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
continue
else:
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 0
conn.llm_finish_task = True
music_path = "config/assets/bind_not_found.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0))
@@ -3,7 +3,6 @@ import json
import asyncio
import time
from core.utils.util import (
remove_punctuation_and_length,
get_string_no_punctuation_or_emoji,
)
@@ -4,6 +4,7 @@ import json
import re
from core.providers.llm.base import LLMProviderBase
import os
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
from cozepy import COZE_CN_BASE_URL
from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa
@@ -16,8 +17,8 @@ logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.personal_access_token = config.get("personal_access_token")
self.bot_id = config.get("bot_id")
self.user_id = config.get("user_id")
self.bot_id = str(config.get("bot_id"))
self.user_id = str(config.get("user_id"))
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
def response(self, session_id, dialogue):
@@ -25,16 +26,13 @@ class LLMProvider(LLMProviderBase):
coze_api_base = COZE_CN_BASE_URL
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user")
coze = Coze(auth=TokenAuth(token=coze_api_token), base_url=coze_api_base)
conversation_id = self.session_conversation_map.get(session_id)
# 如果没有找到conversation_id,则创建新的对话
if not conversation_id:
conversation = coze.conversations.create(
messages=[
]
)
conversation = coze.conversations.create(messages=[])
conversation_id = conversation.id
self.session_conversation_map[session_id] = conversation_id # 更新映射
@@ -68,4 +66,4 @@ class LLMProvider(LLMProviderBase):
dialogue.pop()
for token in self.response(session_id, dialogue):
yield token, None
yield token, None
@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, conint, model_validator
from typing_extensions import Annotated
from datetime import datetime
from typing import Literal
from core.utils.util import check_model_key
from core.utils.util import check_model_key, parse_string_to_list
from core.providers.tts.base import TTSProviderBase
from config.logger import setup_logging
@@ -86,8 +86,8 @@ class TTSProvider(TTSProviderBase):
super().__init__(config, delete_audio_file)
self.reference_id = config.get("reference_id")
self.reference_audio = config.get("reference_audio", [])
self.reference_text = config.get("reference_text", [])
self.reference_audio = parse_string_to_list(config.get("reference_audio"))
self.reference_text = parse_string_to_list(config.get("reference_text"))
self.format = config.get("format", "wav")
self.channels = int(config.get("channels", 1))
self.rate = int(config.get("rate", 44100))
@@ -101,9 +101,13 @@ class TTSProvider(TTSProviderBase):
self.top_p = float(config.get("top_p", 0.7))
self.repetition_penalty = float(config.get("repetition_penalty", 1.2))
self.temperature = float(config.get("temperature", 0.7))
self.streaming = bool(config.get("streaming", False))
self.streaming = str(config.get("streaming", False)).lower() in (
"true",
"1",
"yes",
)
self.use_memory_cache = config.get("use_memory_cache", "on")
self.seed = config.get("seed")
self.seed = config.get("seed") or None
self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts")
def generate_filename(self, extension=".wav"):
@@ -6,6 +6,7 @@ import requests
from config.logger import setup_logging
from datetime import datetime
from core.providers.tts.base import TTSProviderBase
from core.utils.util import parse_string_to_list
TAG = __name__
logger = setup_logging()
@@ -25,14 +26,33 @@ class TTSProvider(TTSProviderBase):
self.text_split_method = config.get("text_split_method", "cut0")
self.batch_size = int(config.get("batch_size", 1))
self.batch_threshold = float(config.get("batch_threshold", 0.75))
self.split_bucket = bool(config.get("split_bucket", True))
self.return_fragment = bool(config.get("return_fragment", False))
self.split_bucket = str(config.get("split_bucket", True)).lower() in (
"true",
"1",
"yes",
)
self.return_fragment = str(config.get("return_fragment", False)).lower() in (
"true",
"1",
"yes",
)
self.speed_factor = float(config.get("speed_factor", 1.0))
self.streaming_mode = bool(config.get("streaming_mode", False))
self.streaming_mode = str(config.get("streaming_mode", False)).lower() in (
"true",
"1",
"yes",
)
self.seed = int(config.get("seed", -1))
self.parallel_infer = bool(config.get("parallel_infer", True))
self.parallel_infer = str(config.get("parallel_infer", True)).lower() in (
"true",
"1",
"yes",
)
self.repetition_penalty = float(config.get("repetition_penalty", 1.35))
self.aux_ref_audio_paths = config.get("aux_ref_audio_paths", [])
self.aux_ref_audio_paths = parse_string_to_list(
config.get("aux_ref_audio_paths")
)
def generate_filename(self, extension=".wav"):
return os.path.join(
@@ -4,6 +4,7 @@ import requests
from config.logger import setup_logging
from datetime import datetime
from core.providers.tts.base import TTSProviderBase
from core.utils.util import parse_string_to_list
TAG = __name__
logger = setup_logging()
@@ -22,9 +23,9 @@ class TTSProvider(TTSProviderBase):
self.temperature = float(config.get("temperature", 1.0))
self.cut_punc = config.get("cut_punc", "")
self.speed = float(config.get("speed", 1.0))
self.inp_refs = config.get("inp_refs", [])
self.inp_refs = parse_string_to_list(config.get("inp_refs"))
self.sample_steps = int(config.get("sample_steps", 32))
self.if_sr = bool(config.get("if_sr", False))
self.if_sr = str(config.get("if_sr", False)).lower() in ("true", "1", "yes")
def generate_filename(self, extension=".wav"):
return os.path.join(
@@ -4,6 +4,7 @@ import json
import requests
from datetime import datetime
from core.providers.tts.base import TTSProviderBase
from core.utils.util import parse_string_to_list
class TTSProvider(TTSProviderBase):
@@ -40,7 +41,7 @@ class TTSProvider(TTSProviderBase):
**config.get("pronunciation_dict", {}),
}
self.audio_setting = {**defult_audio_setting, **config.get("audio_setting", {})}
self.timber_weights = config.get("timber_weights", [])
self.timber_weights = parse_string_to_list(config.get("timber_weights"))
if self.voice_id:
self.voice_setting["voice_id"] = self.voice_id
@@ -16,7 +16,7 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("voice")
self.response_format = config.get("response_format")
self.sample_rate = config.get("sample_rate")
self.speed = float(config.get("speed"))
self.speed = float(config.get("speed", 1.0))
self.gain = config.get("gain")
self.host = "api.siliconflow.cn"
@@ -22,7 +22,7 @@ class TTSProvider(TTSProviderBase):
self.to_lang = config.get("to_lang")
self.volume_change_dB = int(config.get("volume_change_dB", 0))
self.speed_factor = int(config.get("speed_factor", 1))
self.stream = bool(config.get("stream", False))
self.stream = str(config.get("stream", False)).lower() in ("true", "1", "yes")
self.output_file = config.get("output_dir")
self.pitch_factor = int(config.get("pitch_factor", 0))
self.format = config.get("format", "mp3")
+63 -32
View File
@@ -163,6 +163,24 @@ def check_model_key(modelType, modelKey):
return True
def parse_string_to_list(value, separator=";"):
"""
将输入值转换为列表
Args:
value: 输入值,可以是 None、字符串或列表
separator: 分隔符,默认为分号
Returns:
list: 处理后的列表
"""
if value is None or value == "":
return []
elif isinstance(value, str):
return [item.strip() for item in value.split(separator) if item.strip()]
elif isinstance(value, list):
return value
return []
def check_ffmpeg_installed():
ffmpeg_installed = False
try:
@@ -222,80 +240,93 @@ def initialize_modules(
# 初始化TTS模块
if init_tts:
select_tts_module = config["selected_module"]["TTS"]
tts_type = (
config["selected_module"]["TTS"]
if "type" not in config["TTS"][config["selected_module"]["TTS"]]
else config["TTS"][config["selected_module"]["TTS"]]["type"]
select_tts_module
if "type" not in config["TTS"][select_tts_module]
else config["TTS"][select_tts_module]["type"]
)
modules["tts"] = tts.create_instance(
tts_type,
config["TTS"][config["selected_module"]["TTS"]],
bool(config.get("delete_audio", True)),
config["TTS"][select_tts_module],
str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
)
logger.bind(tag=TAG).info(f"初始化组件: tts成功")
logger.bind(tag=TAG).info(f"初始化组件: tts成功 {select_tts_module}")
# 初始化LLM模块
if init_llm:
select_llm_module = config["selected_module"]["LLM"]
llm_type = (
config["selected_module"]["LLM"]
if "type" not in config["LLM"][config["selected_module"]["LLM"]]
else config["LLM"][config["selected_module"]["LLM"]]["type"]
select_llm_module
if "type" not in config["LLM"][select_llm_module]
else config["LLM"][select_llm_module]["type"]
)
modules["llm"] = llm.create_instance(
llm_type,
config["LLM"][config["selected_module"]["LLM"]],
config["LLM"][select_llm_module],
)
logger.bind(tag=TAG).info(f"初始化组件: llm成功")
logger.bind(tag=TAG).info(f"初始化组件: llm成功 {select_llm_module}")
# 初始化Intent模块
if init_intent:
select_intent_module = config["selected_module"]["Intent"]
intent_type = (
config["selected_module"]["Intent"]
if "type" not in config["Intent"][config["selected_module"]["Intent"]]
else config["Intent"][config["selected_module"]["Intent"]]["type"]
select_intent_module
if "type" not in config["Intent"][select_intent_module]
else config["Intent"][select_intent_module]["type"]
)
modules["intent"] = intent.create_instance(
intent_type,
config["Intent"][config["selected_module"]["Intent"]],
config["Intent"][select_intent_module],
)
logger.bind(tag=TAG).info(f"初始化组件: intent成功")
logger.bind(tag=TAG).info(f"初始化组件: intent成功 {select_intent_module}")
# 初始化Memory模块
if init_memory:
select_memory_module = config["selected_module"]["Memory"]
memory_type = (
config["selected_module"]["Memory"]
if "type" not in config["Memory"][config["selected_module"]["Memory"]]
else config["Memory"][config["selected_module"]["Memory"]]["type"]
select_memory_module
if "type" not in config["Memory"][select_memory_module]
else config["Memory"][select_memory_module]["type"]
)
modules["memory"] = memory.create_instance(
memory_type,
config["Memory"][config["selected_module"]["Memory"]],
config["Memory"][select_memory_module],
)
logger.bind(tag=TAG).info(f"初始化组件: memory成功")
logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}")
# 初始化VAD模块
if init_vad:
select_vad_module = config["selected_module"]["VAD"]
vad_type = (
config["selected_module"]["VAD"]
if "type" not in config["VAD"][config["selected_module"]["VAD"]]
else config["VAD"][config["selected_module"]["VAD"]]["type"]
select_vad_module
if "type" not in config["VAD"][select_vad_module]
else config["VAD"][select_vad_module]["type"]
)
modules["vad"] = vad.create_instance(
vad_type,
config["VAD"][config["selected_module"]["VAD"]],
config["VAD"][select_vad_module],
)
logger.bind(tag=TAG).info(f"初始化组件: vad成功")
logger.bind(tag=TAG).info(f"初始化组件: vad成功 {select_vad_module}")
# 初始化ASR模块
if init_asr:
select_asr_module = config["selected_module"]["ASR"]
asr_type = (
config["selected_module"]["ASR"]
if "type" not in config["ASR"][config["selected_module"]["ASR"]]
else config["ASR"][config["selected_module"]["ASR"]]["type"]
select_asr_module
if "type" not in config["ASR"][select_asr_module]
else config["ASR"][select_asr_module]["type"]
)
modules["asr"] = asr.create_instance(
asr_type,
config["ASR"][config["selected_module"]["ASR"]],
bool(config.get("delete_audio", True)),
config["ASR"][select_asr_module],
str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
)
logger.bind(tag=TAG).info(f"初始化组件: asr成功")
logger.bind(tag=TAG).info(f"初始化组件: asr成功 {select_asr_module}")
# 初始化自定义prompt
if config.get("prompt", None) is not None:
modules["prompt"] = config["prompt"]
logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {modules['prompt'][:50]}...")
return modules
@@ -992,7 +992,9 @@ function connectToServer() {
if(connectButton.id === "connectButton") {
connectButton.textContent = '断开';
connectButton.onclick = disconnectFromServer;
// connectButton.onclick = disconnectFromServer;
connectButton.removeEventListener("click", connectToServer);
connectButton.addEventListener("click", disconnectFromServer);
}
if(messageInput.id === "messageInput") {
@@ -1011,7 +1013,9 @@ function connectToServer() {
if(connectButton.id === "connectButton") {
connectButton.textContent = '连接';
connectButton.onclick = connectToServer;
// connectButton.onclick = connectToServer;
connectButton.removeEventListener("click", disconnectFromServer);
connectButton.addEventListener("click", connectToServer);
}
if(messageInput.id === "messageInput") {
@@ -1175,7 +1179,7 @@ function updateStatus(message, type = 'info') {
// 处理文本消息
function handleTextMessage(message) {
if (message.type === 'hello') {
console.log(`服务器回应:${message.message}`);
console.log(`服务器回应:${JSON.stringify(message, null, 2)}`);
} else if (message.type === 'tts') {
// TTS状态消息
if (message.state === 'start') {
+410 -106
View File
@@ -42,6 +42,50 @@
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.section h2 .toggle-button {
margin-left: auto;
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
height: 28px;
line-height: 20px;
}
.device-info {
display: flex;
align-items: center;
gap: 20px;
margin-left: 20px;
padding: 0 15px;
background-color: #f9f9f9;
border-radius: 4px;
height: 28px;
line-height: 28px;
}
.device-info span {
color: #666;
font-size: 13px;
}
.device-info strong {
color: #333;
font-weight: 500;
}
.config-panel {
display: none;
transition: all 0.3s ease;
margin-top: 5px;
padding: 5px 0;
}
.config-panel.expanded {
display: block;
}
.control-panel {
@@ -70,7 +114,8 @@
cursor: not-allowed;
}
#serverUrl {
#serverUrl,
#otaUrl {
flex-grow: 1;
padding: 8px;
border: 1px solid #ddd;
@@ -316,6 +361,76 @@
gap: 20px;
margin-top: 10px;
}
.config-item {
display: flex;
align-items: center;
margin-bottom: 8px;
width: 100%;
}
.config-item label {
width: 100px;
text-align: right;
margin-right: 10px;
color: #666;
}
.config-item input {
flex-grow: 1;
padding: 6px;
border: 1px solid #ddd;
border-radius: 5px;
}
.control-panel {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.connection-controls {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
}
.connection-controls input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 5px;
min-width: 200px;
}
.connection-controls button {
white-space: nowrap;
padding: 8px 15px;
}
.connection-status {
display: flex;
align-items: center;
gap: 20px;
margin-left: 20px;
padding: 0 15px;
background-color: #f9f9f9;
border-radius: 4px;
height: 28px;
line-height: 28px;
}
.connection-status span {
color: #666;
font-size: 13px;
}
.connection-status .status {
color: #333;
font-weight: 500;
}
</style>
</head>
@@ -326,17 +441,56 @@
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
正在加载Opus库...</div>
<!-- 添加配置面板 -->
<div class="section">
<h2>WebSocket连接 <span id="connectionStatus" class="status">未连接</span></h2>
<div class="control-panel">
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/" placeholder="WebSocket服务器地址">
<h2>
设备配置
<span class="device-info">
<span>MAC: <strong id="displayMac">00:11:22:33:44:55</strong></span>
<span>客户端: <strong id="displayClient">web_test_client</strong></span>
</span>
<button class="toggle-button" id="toggleConfig">编辑</button>
</h2>
<div class="config-panel" id="configPanel">
<div class="control-panel">
<div class="config-item">
<label for="deviceMac">设备MAC:</label>
<input type="text" id="deviceMac" value="00:11:22:33:44:55" placeholder="设备MAC地址">
</div>
<div class="config-item">
<label for="deviceName">设备名称:</label>
<input type="text" id="deviceName" value="Web测试设备" placeholder="设备名称">
</div>
<div class="config-item">
<label for="clientId">客户端ID:</label>
<input type="text" id="clientId" value="web_test_client" placeholder="客户端ID">
</div>
<div class="config-item">
<label for="token">认证Token:</label>
<input type="text" id="token" value="your-token1" placeholder="认证Token">
</div>
</div>
</div>
</div>
<div class="section">
<h2>
连接信息
<span class="connection-status">
<span>OTA: <span id="otaStatus" class="status">ota未连接</span></span>
<span>WS: <span id="connectionStatus" class="status">ws未连接</span></span>
</span>
</h2>
<div class="connection-controls">
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/" placeholder="OTA服务器地址" />
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/"
placeholder="WebSocket服务器地址" />
<button id="connectButton">连接</button>
<button id="authTestButton">测试认证</button>
</div>
</div>
<div class="section">
<h2>消息发送</h2>
<div class="tabs">
<button class="tab active" data-tab="text">文本消息</button>
<button class="tab" data-tab="voice">语音消息</button>
@@ -475,22 +629,36 @@
// 日志函数
function log(message, type = 'info') {
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
if (type === 'error') {
logEntry.style.color = 'red';
} else if (type === 'debug') {
logEntry.style.color = 'gray';
return;
} else if (type === 'warning') {
logEntry.style.color = 'orange';
} else if (type === 'success') {
logEntry.style.color = 'green';
} else {
logEntry.style.color = 'black';
}
logContainer.appendChild(logEntry);
// 将消息按换行符分割成多行
const lines = message.split('\n');
const now = new Date();
// const timestamp = `[${now.toLocaleTimeString()}] `;
const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `;
// 为每一行创建日志条目
lines.forEach((line, index) => {
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
// 如果是第一条日志,显示时间戳
const prefix = index === 0 ? timestamp : ' '.repeat(timestamp.length);
logEntry.textContent = `${prefix}${line}`;
// logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
// logEntry.style 保留起始的空格
logEntry.style.whiteSpace = 'pre';
if (type === 'error') {
logEntry.style.color = 'red';
} else if (type === 'debug') {
logEntry.style.color = 'gray';
return;
} else if (type === 'warning') {
logEntry.style.color = 'orange';
} else if (type === 'success') {
logEntry.style.color = 'green';
} else {
logEntry.style.color = 'black';
}
logContainer.appendChild(logEntry);
});
logContainer.scrollTop = logContainer.scrollHeight;
}
@@ -540,16 +708,16 @@
// 开始音频缓冲过程
function startAudioBuffering() {
if (isAudioBuffering || isAudioPlaying) return;
isAudioBuffering = true;
log("开始音频缓冲...", 'info');
// 先尝试初始化解码器,以便在播放时已准备好
initOpusDecoder().catch(error => {
log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
// 继续缓冲,我们会在播放时再次尝试初始化
});
// 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放
setTimeout(() => {
if (isAudioBuffering && audioBufferQueue.length > 0) {
@@ -557,14 +725,14 @@
playBufferedAudio();
}
}, 300); // 300ms超时
// 监控缓冲进度
const bufferCheckInterval = setInterval(() => {
if (!isAudioBuffering) {
clearInterval(bufferCheckInterval);
return;
}
// 当累积了足够的音频包,开始播放
if (audioBufferQueue.length >= BUFFER_THRESHOLD) {
clearInterval(bufferCheckInterval);
@@ -577,10 +745,10 @@
// 播放已缓冲的音频
function playBufferedAudio() {
if (isAudioPlaying || audioBufferQueue.length === 0) return;
isAudioPlaying = true;
isAudioBuffering = false;
// 确保Opus解码器已初始化
const initDecoderAndPlay = async () => {
try {
@@ -591,7 +759,7 @@
});
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
}
// 确保解码器已初始化
if (!opusDecoder) {
log('初始化Opus解码器...', 'info');
@@ -607,7 +775,7 @@
return;
}
}
// 创建流式播放上下文
if (!streamingContext) {
streamingContext = {
@@ -617,16 +785,16 @@
source: null, // 当前音频源
totalSamples: 0, // 累积的总样本数
lastPlayTime: 0, // 上次播放的时间戳
// 将Opus数据解码为PCM
decodeOpusFrames: async function(opusFrames) {
decodeOpusFrames: async function (opusFrames) {
if (!opusDecoder) {
log('Opus解码器未初始化,无法解码', 'error');
return;
}
let decodedSamples = [];
for (const frame of opusFrames) {
try {
// 使用Opus解码器解码
@@ -640,12 +808,12 @@
log("Opus解码失败: " + error.message, 'error');
}
}
if (decodedSamples.length > 0) {
// 添加到解码队列
this.queue.push(...decodedSamples);
this.totalSamples += decodedSamples.length;
// 如果累积了至少0.2秒的音频,开始播放
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION;
if (!this.playing && this.queue.length >= minSamples) {
@@ -655,50 +823,50 @@
log('没有成功解码的样本', 'warning');
}
},
// 开始播放音频
startPlaying: function() {
startPlaying: function () {
if (this.playing || this.queue.length === 0) return;
this.playing = true;
// 创建新的音频缓冲区
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒
const currentSamples = this.queue.splice(0, minPlaySamples);
const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
// 创建音频源
this.source = audioContext.createBufferSource();
this.source.buffer = audioBuffer;
// 创建增益节点用于平滑过渡
const gainNode = audioContext.createGain();
// 应用淡入淡出效果避免爆音
const fadeDuration = 0.02; // 20毫秒
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
const duration = audioBuffer.duration;
if (duration > fadeDuration * 2) {
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
}
// 连接节点并开始播放
this.source.connect(gainNode);
gainNode.connect(audioContext.destination);
this.lastPlayTime = audioContext.currentTime;
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)}`, 'info');
// 播放结束后的处理
this.source.onended = () => {
this.source = null;
this.playing = false;
// 如果队列中还有数据或者缓冲区有新数据,继续播放
if (this.queue.length > 0) {
setTimeout(() => this.startPlaying(), 10);
@@ -729,26 +897,26 @@
}, 500); // 500ms超时
}
};
this.source.start();
}
};
}
// 开始处理缓冲的数据
const frames = [...audioBufferQueue];
audioBufferQueue = []; // 清空缓冲队列
// 解码并播放
await streamingContext.decodeOpusFrames(frames);
} catch (error) {
log(`播放已缓冲的音频出错: ${error.message}`, 'error');
isAudioPlaying = false;
streamingContext = null;
}
};
// 执行初始化和播放
initDecoderAndPlay();
}
@@ -766,7 +934,7 @@
// 初始化Opus解码器 - 确保完全初始化完成后才返回
async function initOpusDecoder() {
if (opusDecoder) return opusDecoder; // 已经初始化
try {
// 检查ModuleInstance是否存在
if (typeof window.ModuleInstance === 'undefined') {
@@ -778,9 +946,9 @@
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
}
}
const mod = window.ModuleInstance;
// 创建解码器对象
opusDecoder = {
channels: CHANNELS,
@@ -788,55 +956,55 @@
frameSize: FRAME_SIZE,
module: mod,
decoderPtr: null, // 初始为null
// 初始化解码器
init: function() {
init: function () {
if (this.decoderPtr) return true; // 已经初始化
// 获取解码器大小
const decoderSize = mod._opus_decoder_get_size(this.channels);
log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
// 分配内存
this.decoderPtr = mod._malloc(decoderSize);
if (!this.decoderPtr) {
throw new Error("无法分配解码器内存");
}
// 初始化解码器
const err = mod._opus_decoder_init(
this.decoderPtr,
this.rate,
this.channels
);
if (err < 0) {
this.destroy(); // 清理资源
throw new Error(`Opus解码器初始化失败: ${err}`);
}
log("Opus解码器初始化成功", 'success');
return true;
},
// 解码方法
decode: function(opusData) {
decode: function (opusData) {
if (!this.decoderPtr) {
if (!this.init()) {
throw new Error("解码器未初始化且无法初始化");
}
}
try {
const mod = this.module;
// 为Opus数据分配内存
const opusPtr = mod._malloc(opusData.length);
mod.HEAPU8.set(opusData, opusPtr);
// 为PCM输出分配内存
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
// 解码
const decodedSamples = mod._opus_decode(
this.decoderPtr,
@@ -846,46 +1014,46 @@
this.frameSize,
0 // 不使用FEC
);
if (decodedSamples < 0) {
mod._free(opusPtr);
mod._free(pcmPtr);
throw new Error(`Opus解码失败: ${decodedSamples}`);
}
// 复制解码后的数据
const decodedData = new Int16Array(decodedSamples);
for (let i = 0; i < decodedSamples; i++) {
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
}
// 释放内存
mod._free(opusPtr);
mod._free(pcmPtr);
return decodedData;
} catch (error) {
log(`Opus解码错误: ${error.message}`, 'error');
return new Int16Array(0);
}
},
// 销毁方法
destroy: function() {
destroy: function () {
if (this.decoderPtr) {
this.module._free(this.decoderPtr);
this.decoderPtr = null;
}
}
};
// 初始化解码器
if (!opusDecoder.init()) {
throw new Error("Opus解码器初始化失败");
}
return opusDecoder;
} catch (error) {
log(`Opus解码器初始化失败: ${error.message}`, 'error');
opusDecoder = null; // 重置为null,以便下次重试
@@ -1118,25 +1286,100 @@
}
// 连接WebSocket服务器
function connectToServer() {
async function connectToServer() {
const url = serverUrlInput.value.trim();
if (url === '') return;
try {
// 获取并验证配置
const config = getConfig();
if (!validateConfig(config)) {
return;
}
// 检查URL格式
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
log('URL格式错误,必须以ws://或wss://开头', 'error');
return;
}
// 先检查OTA状态
log('正在检查OTA状态...', 'info');
const otaUrl = document.getElementById('otaUrl').value.trim();
try {
const otaResponse = await fetch(otaUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Device-Id': config.deviceId,
'Client-Id': config.clientId
},
body: JSON.stringify({
"version": 0,
"uuid": "",
"application": {
"name": "xiaozhi-web-test",
"version": "1.0.0",
"compile_time": "2025-04-16 10:00:00",
"idf_version": "4.4.3",
"elf_sha256": "1234567890abcdef1234567890abcdef1234567890abcdef"
},
"ota": {
"label": "xiaozhi-web-test",
},
"board": {
"type": "xiaozhi-web-test",
"ssid": "xiaozhi-web-test",
"rssi": 0,
"channel": 0,
"ip": "192.168.1.1",
"mac": config.deviceMac
},
"flash_size": 0,
"minimum_free_heap_size": 0,
"mac_address": config.deviceMac,
"chip_model_name": "",
"chip_info": {
"model": 0,
"cores": 0,
"revision": 0,
"features": 0
},
"partition_table": [
{
"label": "",
"type": 0,
"subtype": 0,
"address": 0,
"size": 0
}
]
})
});
if (!otaResponse.ok) {
throw new Error(`OTA检查失败: ${otaResponse.status} ${otaResponse.statusText}`);
}
const otaResult = await otaResponse.json();
log(`OTA检查结果: ${JSON.stringify(otaResult)}`, 'info');
log('OTA检查通过,开始连接WebSocket...', 'success');
document.getElementById('otaStatus').textContent = 'ota已连接';
document.getElementById('otaStatus').style.color = 'green';
} catch (error) {
log(`OTA检查错误: ${error.message}`, 'error');
document.getElementById('otaStatus').textContent = 'ota未连接';
document.getElementById('otaStatus').style.color = 'red';
}
// 使用自定义WebSocket实现以添加认证头信息
// 注意:浏览器原生WebSocket不支持自定义头,需要通过服务器添加一个代理层
// 但我们可以通过URL参数模拟认证信息
let connUrl = new URL(url);
// 添加认证参数
connUrl.searchParams.append('device_id', 'web_test_device');
connUrl.searchParams.append('device_mac', '00:11:22:33:44:55');
connUrl.searchParams.append('device-id', config.deviceId);
connUrl.searchParams.append('client-id', config.clientId);
log(`正在连接: ${connUrl.toString()}`, 'info');
websocket = new WebSocket(connUrl.toString());
@@ -1146,14 +1389,16 @@
websocket.onopen = async () => {
log(`已连接到服务器: ${url}`, 'success');
connectionStatus.textContent = '已连接';
connectionStatus.textContent = 'ws已连接';
connectionStatus.style.color = 'green';
// 连接成功后发送hello消息
await sendHelloMessage();
connectButton.textContent = '断开';
connectButton.onclick = disconnectFromServer;
connectButton.removeEventListener('click', connectToServer);
connectButton.addEventListener('click', disconnectFromServer);
// connectButton.onclick = disconnectFromServer;
messageInput.disabled = false;
sendTextButton.disabled = false;
@@ -1165,11 +1410,13 @@
websocket.onclose = () => {
log('已断开连接', 'info');
connectionStatus.textContent = '已断开';
connectionStatus.textContent = 'ws已断开';
connectionStatus.style.color = 'red';
connectButton.textContent = '连接';
connectButton.onclick = connectToServer;
connectButton.removeEventListener('click', disconnectFromServer);
connectButton.addEventListener('click', connectToServer);
// connectButton.onclick = connectToServer;
messageInput.disabled = true;
sendTextButton.disabled = true;
recordButton.disabled = true;
@@ -1178,7 +1425,7 @@
websocket.onerror = (error) => {
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
connectionStatus.textContent = '连接错误';
connectionStatus.textContent = 'ws未连接';
connectionStatus.style.color = 'red';
};
@@ -1189,7 +1436,7 @@
const message = JSON.parse(event.data);
if (message.type === 'hello') {
log(`服务器回应:${message.message}`, 'info');
log(`服务器回应:${JSON.stringify(message, null, 2)}`, 'success');
} else if (message.type === 'tts') {
// TTS状态消息
if (message.state === 'start') {
@@ -1244,11 +1491,11 @@
}
};
connectionStatus.textContent = '正在连接...';
connectionStatus.textContent = 'ws未连接';
connectionStatus.style.color = 'orange';
} catch (error) {
log(`连接错误: ${error.message}`, 'error');
connectionStatus.textContent = '连接失败';
connectionStatus.textContent = 'ws未连接';
}
}
@@ -1257,13 +1504,15 @@
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
try {
const config = getConfig();
// 设置设备信息
const helloMessage = {
type: 'hello',
device_id: 'web_test_device',
device_name: 'Web测试设备',
device_mac: '00:11:22:33:44:55',
token: 'your-token1' // 使用config.yaml中配置的token
device_id: config.deviceId,
device_name: config.deviceName,
device_mac: config.deviceMac,
token: config.token
};
log('发送hello握手消息', 'info');
@@ -1338,6 +1587,34 @@
connectButton.addEventListener('click', connectToServer);
document.getElementById('authTestButton').addEventListener('click', testAuthentication);
// 设备配置面板折叠/展开
const toggleButton = document.getElementById('toggleConfig');
const configPanel = document.getElementById('configPanel');
const deviceMacInput = document.getElementById('deviceMac');
const clientIdInput = document.getElementById('clientId');
const displayMac = document.getElementById('displayMac');
const displayClient = document.getElementById('displayClient');
// 更新显示的值
function updateDisplayValues() {
displayMac.textContent = deviceMacInput.value;
displayClient.textContent = clientIdInput.value;
}
// 监听输入变化
deviceMacInput.addEventListener('input', updateDisplayValues);
clientIdInput.addEventListener('input', updateDisplayValues);
// 初始更新显示值
updateDisplayValues();
// 切换面板显示
toggleButton.addEventListener('click', () => {
const isExpanded = configPanel.classList.contains('expanded');
configPanel.classList.toggle('expanded');
toggleButton.textContent = isExpanded ? '编辑' : '收起';
});
// 标签页切换
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
@@ -1372,12 +1649,14 @@
async function testAuthentication() {
log('开始测试认证...', 'info');
const config = getConfig();
// 显示服务器配置
log('-------- 服务器认证配置检查 --------', 'info');
log('请确认config.yaml中的auth配置:', 'info');
log('1. server.auth.enabled 为 false 或服务器已正确配置认证', 'info');
log('2. 如果启用了认证,请确认使用了正确的token', 'info');
log('3. 或者在allowed_devices中添加了测试设备MAC00:11:22:33:44:55', 'info');
log(`3. 或者在allowed_devices中添加了测试设备MAC${config.deviceMac}`, 'info');
const serverUrl = serverUrlInput.value.trim();
if (!serverUrl) {
@@ -1418,9 +1697,9 @@
log('测试2: 尝试带token参数连接...', 'info');
let url = new URL(serverUrl);
url.searchParams.append('token', 'your-token1');
url.searchParams.append('device_id', 'web_test_device');
url.searchParams.append('device_mac', '00:11:22:33:44:55');
url.searchParams.append('token', config.token);
url.searchParams.append('device_id', config.deviceId);
url.searchParams.append('device_mac', config.deviceMac);
const ws2 = new WebSocket(url.toString());
@@ -1430,9 +1709,9 @@
// 尝试发送hello消息
const helloMsg = {
type: 'hello',
device_id: 'web_test_device',
device_mac: '00:11:22:33:44:55',
token: 'your-token1'
device_id: config.deviceId,
device_mac: config.deviceMac,
token: config.token
};
ws2.send(JSON.stringify(helloMsg));
@@ -2074,19 +2353,19 @@
if (opusData.length > 0) {
// 将数据添加到缓冲队列
audioBufferQueue.push(opusData);
// 如果收到的是第一个音频包,开始缓冲过程
if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) {
startAudioBuffering();
}
} else {
log('收到空音频数据帧,可能是结束标志', 'warning');
// 如果缓冲队列中有数据且没有在播放,立即开始播放
if (audioBufferQueue.length > 0 && !isAudioPlaying) {
playBufferedAudio();
}
// 如果正在播放,发送结束信号
if (isAudioPlaying && streamingContext) {
streamingContext.endOfStream = true;
@@ -2097,6 +2376,31 @@
}
}
// 获取配置值
function getConfig() {
const deviceMac = document.getElementById('deviceMac').value.trim();
return {
deviceId: deviceMac, // 使用MAC地址作为deviceId
deviceName: document.getElementById('deviceName').value.trim(),
deviceMac: deviceMac,
clientId: document.getElementById('clientId').value.trim(),
token: document.getElementById('token').value.trim()
};
}
// 验证配置
function validateConfig(config) {
if (!config.deviceMac) {
log('设备MAC地址不能为空', 'error');
return false;
}
if (!config.clientId) {
log('客户端ID不能为空', 'error');
return false;
}
return true;
}
initApp();
</script>
</body>