Compare commits

..
17 Commits
Author SHA1 Message Date
hrzandGitHub de5eaf4467 更新版本号0.3.5 (#852)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug

* udpate:增加iot消息properties和methods可能为空的情况

* update:发布0.3.5版本
2025-04-16 23:47:37 +08:00
hrzandGitHub ca884833d1 udpate:增加iot消息properties和methods可能为空的情况 (#851)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug

* udpate:增加iot消息properties和methods可能为空的情况
2025-04-16 23:34:23 +08:00
hrzandGitHub 0da2da83a5 update:修复智控台下发配置布尔类型转换出错bug (#850)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug
2025-04-16 22:55:13 +08:00
hrzandGitHub bfdfa44edd Merge pull request #844 from xinnan-tech/web-page-modify
“角色配置”行内样式调整
2025-04-16 18:43:07 +08:00
hrzandGitHub ed1cfe5eb6 update:兼容旧设备,无Client-Id的情况 (#847)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况
2025-04-16 18:03:30 +08:00
Ran_Chen b696f3f5db “角色配置”行内样式调整 2025-04-16 17:18:11 +08:00
hrzandGitHub 7a0cf5ef9a update:测试页面增加OTA地址 (#842) 2025-04-16 16:44:44 +08:00
CGDandGitHub d1b7cb6a36 Merge pull request #837 from xinnan-tech/web-modify
“角色配置”页面间距调整
2025-04-16 14:47:58 +08:00
Ran_Chen b21d699472 “角色配置”页面间距调整 2025-04-16 14:25:46 +08:00
CGDandGitHub 029fadd778 Merge pull request #836 from xinnan-tech/web-modify
Web modify
2025-04-16 14:04:19 +08:00
Sakura-RanChenandGitHub 215d50235b Merge pull request #835 from xinnan-tech/web-vue-agent-modify
修改了部分components的样式
2025-04-16 14:03:24 +08:00
CGD 000e676397 修改了部分components的样式 2025-04-16 12:55:07 +08:00
Ran_Chen ee20f59a8e 增加“回车”事件 2025-04-16 11:42:05 +08:00
Ran_Chen 925cf3dabb 新增智能体按钮绑定问题 2025-04-16 11:34:30 +08:00
CGDandGitHub 0049d0e926 Merge pull request #831 from xinnan-tech/web-pages-modify
Web pages modify
2025-04-16 11:23:42 +08:00
Ran_Chen 56959808a9 解决新增设备按钮绑定问题,优化“角色配置”页面 2025-04-16 11:17:02 +08:00
Ran_Chen 8667a9680e 增加时间戳转换,修改各页面的布局 2025-04-16 10:15:58 +08:00
27 changed files with 837 additions and 344 deletions
@@ -38,10 +38,13 @@ public class OTAController {
public ResponseEntity<String> checkOTAVersion( public ResponseEntity<String> checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO, @RequestBody DeviceReportReqDTO deviceReportReqDTO,
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId, @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) { @Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
if (StringUtils.isAnyBlank(deviceId, clientId)) { if (StringUtils.isBlank(deviceId)) {
return createResponse(DeviceReportRespDTO.createError("Device ID is required")); return createResponse(DeviceReportRespDTO.createError("Device ID is required"));
} }
if (StringUtils.isBlank(clientId)) {
clientId = deviceId;
}
String macAddress = deviceReportReqDTO.getMacAddress(); String macAddress = deviceReportReqDTO.getMacAddress();
boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress); boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段 // 设备Id和Mac地址应是一致的, 并且必须需要application字段
@@ -121,7 +121,7 @@ public class LoginController {
@Operation(summary = "公共配置") @Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() { public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
config.put("version", "0.3.4"); config.put("version", "0.3.5");
config.put("allowUserRegister", sysUserService.getAllowUserRegister()); config.put("allowUserRegister", sysUserService.getAllowUserRegister());
return new Result<Map<String, Object>>().ok(config); return new Result<Map<String, Object>>().ok(config);
} }
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible.sync="visible" width="400px" center> <el-dialog :visible="visible" @close="handleClose" width="400px" center>
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -79,7 +79,10 @@ export default {
cancel() { cancel() {
this.$emit('update:visible', false) this.$emit('update:visible', false)
this.deviceCode = "" this.deviceCode = ""
} },
handleClose() {
this.$emit('update:visible', false);
},
} }
} }
</script> </script>
@@ -44,7 +44,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="排序号" prop="sortOrder" style="flex: 1;"> <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> </el-form-item>
</div> </div>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible.sync="visible" width="400px" center @open="handleOpen"> <el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -66,7 +66,10 @@ export default {
cancel() { cancel() {
this.$emit('update:visible', false) this.$emit('update:visible', false)
this.wisdomBodyName = "" this.wisdomBodyName = ""
} },
handleClose() {
this.$emit('update:visible', false);
},
} }
} }
</script> </script>
@@ -83,7 +83,8 @@ export default {
font-size: 10px; font-size: 10px;
color: #5778ff; color: #5778ff;
background: #e6ebff; background: #e6ebff;
width: 57px; width: auto;
padding: 0 12px;
height: 21px; height: 21px;
line-height: 21px; line-height: 21px;
cursor: pointer; cursor: pointer;
+15 -11
View File
@@ -551,28 +551,26 @@ export default {
/* 备注文本 */ /* 备注文本 */
::v-deep .remark-input .el-textarea__inner { ::v-deep .remark-input .el-textarea__inner {
background-color: #f5f5f5;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e6e6e6; border: 1px solid #e6e6e6;
padding: 8px 12px; padding: 8px 12px;
resize: none; resize: none;
max-height: 40px !important; max-height: 40px !important;
line-height: 1.5; line-height: 1.5;
} background-color: transparent !important;
::v-deep .remark-input .el-textarea__inner::placeholder {
color: black !important;
opacity: 0.7;
}
::v-deep .remark-input .el-textarea__inner {
background-color: #f4f6fa;
} }
::v-deep .remark-input .el-textarea__inner:focus { ::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 { .scroll-wrapper {
display: flex; display: flex;
@@ -650,6 +648,12 @@ export default {
bottom: 20px; bottom: 20px;
padding-top: 10px; padding-top: 10px;
} }
.action-buttons .el-button {
padding: 8px 15px;
font-size: 11px;
}
.edit-btn, .edit-btn,
.delete-btn, .delete-btn,
.save-btn { .save-btn {
@@ -115,8 +115,8 @@ export default {
activeSearchKeyword: "", activeSearchKeyword: "",
currentAgentId: this.$route.query.agentId || '', currentAgentId: this.$route.query.agentId || '',
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
pageSizeOptions: [5, 10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
deviceList: [], deviceList: [],
loading: false, loading: false,
userApi: null, userApi: null,
@@ -283,13 +283,18 @@ export default {
this.deviceList = data.data.map(device => { this.deviceList = data.data.map(device => {
const bindDate = new Date(device.createDate); 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')}`; 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 { return {
device_id: device.id, device_id: device.id,
model: device.board, model: device.board,
firmwareVersion: device.appVersion, firmwareVersion: device.appVersion,
macAddress: device.macAddress, macAddress: device.macAddress,
bindTime: formattedBindTime, bindTime: formattedBindTime,
lastConversation: device.lastConnectedAt, lastConversation: formattedLastConversation,
remark: device.alias, remark: device.alias,
isEdit: false, isEdit: false,
otaSwitch: device.autoUpdate === 1, otaSwitch: device.autoUpdate === 1,
@@ -331,7 +336,7 @@ export default {
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 200px); min-height: calc(100vh - 24vh);
height: auto; height: auto;
max-height: 80vh; max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -628,12 +633,12 @@ export default {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - 330px); max-height: calc(100vh - 40vh);
} }
:deep(.el-table__body-wrapper) { :deep(.el-table__body-wrapper) {
flex: 1; flex: 1;
overflow: auto; overflow-y: auto;
max-height: none !important; max-height: none !important;
} }
+3 -4
View File
@@ -152,7 +152,7 @@ export default {
ttsDialogVisible: false, ttsDialogVisible: false,
selectedTtsModelId: '', selectedTtsModelId: '',
modelList: [], modelList: [],
pageSizeOptions: [5, 10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
currentPage: 1, currentPage: 1,
pageSize: 10, pageSize: 10,
total: 0, total: 0,
@@ -461,7 +461,7 @@ export default {
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 235px); min-height: calc(100vh - 24vh);
height: auto; height: auto;
max-height: 80vh; max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -656,7 +656,6 @@ export default {
} }
.table-footer { .table-footer {
margin-top: 24px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -904,7 +903,7 @@ export default {
} }
.data-table { .data-table {
--table-max-height: calc(100vh - 450px); --table-max-height: calc(100vh - 45vh);
max-height: var(--table-max-height); max-height: var(--table-max-height);
} }
@@ -92,8 +92,8 @@ export default {
searchCode: "", searchCode: "",
paramsList: [], paramsList: [],
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
pageSizeOptions: [5, 10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
total: 0, total: 0,
dialogVisible: false, dialogVisible: false,
dialogTitle: "新增参数", dialogTitle: "新增参数",
@@ -330,7 +330,7 @@ export default {
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 350px); min-height: calc(100vh - 24vh);
height: auto; height: auto;
max-height: 80vh; max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -516,7 +516,7 @@ export default {
flex-direction: column; flex-direction: column;
.el-table__body-wrapper { .el-table__body-wrapper {
flex: 1; flex: 1;
overflow: auto; overflow-y: auto;
max-height: none !important; max-height: none !important;
} }
@@ -649,7 +649,7 @@ export default {
} }
.el-table { .el-table {
--table-max-height: calc(100vh - 400px); --table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height); max-height: var(--table-max-height);
.el-table__body-wrapper { .el-table__body-wrapper {
@@ -99,9 +99,9 @@ export default {
currentPassword: "", currentPassword: "",
searchPhone: "", searchPhone: "",
userList: [], userList: [],
pageSizeOptions: [5, 10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
total: 0, total: 0,
isAllSelected: false isAllSelected: false
}; };
@@ -355,7 +355,7 @@ export default {
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 350px); min-height: calc(100vh - 24vh);
height: auto; height: auto;
max-height: 80vh; max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -557,7 +557,7 @@ export default {
flex-direction: column; flex-direction: column;
.el-table__body-wrapper { .el-table__body-wrapper {
flex: 1; flex: 1;
overflow: auto; overflow-y: auto;
max-height: none !important; max-height: none !important;
} }
@@ -674,7 +674,7 @@ export default {
} }
.el-table { .el-table {
--table-max-height: calc(100vh - 400px); --table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height); max-height: var(--table-max-height);
.el-table__body-wrapper { .el-table__body-wrapper {
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="welcome"> <div class="welcome" @keyup.enter="register">
<el-container style="height: 100%;"> <el-container style="height: 100%;">
<!-- 保持相同的头部 --> <!-- 保持相同的头部 -->
<el-header> <el-header>
+279 -162
View File
@@ -1,88 +1,126 @@
<template> <template>
<div class="welcome"> <div class="welcome">
<HeaderBar /> <HeaderBar/>
<el-main style="padding: 16px;display: flex;flex-direction: column;">
<div style="border-radius: 16px;background: #fafcfe; border: 1px solid #e8f0ff;"> <div class="operation-bar">
<div <h2 class="page-title">角色配置</h2>
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;"> </div>
<div
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;"> <div class="main-wrapper">
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;" /> <div class="content-panel">
</div> <div class="content-area">
{{ form.agentName }} <el-card class="config-card" shadow="never">
</div> <div class="config-header">
<div style="height: 1px;background: #e8f0ff;" /> <div class="header-icon">
<el-form ref="form" :model="form" label-width="72px"> <img loading="lazy" src="@/assets/home/setting-user.png" alt="">
<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>
</div> </div>
<span class="header-title">{{ form.agentName }}</span>
</div> </div>
</div> <div class="divider"></div>
</el-form>
<div style="display: flex;padding: 16px;gap: 8px;align-items: center;"> <el-form ref="form" :model="form" label-width="72px">
<div class="save-btn" @click="saveConfig"> <div class="form-content">
保存配置 <div class="form-grid">
</div> <div class="form-column">
<div class="reset-btn" @click="resetConfig"> <el-form-item label="助手昵称:">
重制 <el-input v-model="form.agentName" class="form-input"/>
</div> </el-form-item>
<div class="clear-text"> <el-form-item label="角色模版:">
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;" /> <div class="template-container">
保存配置后需要重启设备新的配置才会生效 <div
</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>
</div> </div>
</div>
</el-main>
</div> </div>
</template> </template>
@@ -90,10 +128,9 @@
import Api from '@/apis/api'; import Api from '@/apis/api';
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
export default { export default {
name: 'RoleConfigPage', name: 'RoleConfigPage',
components: { HeaderBar }, components: {HeaderBar},
data() { data() {
return { return {
form: { form: {
@@ -114,12 +151,12 @@ export default {
} }
}, },
models: [ models: [
{ label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD' }, {label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD'},
{ label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR' }, {label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR'},
{ label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM' }, {label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM'},
{ label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent' }, {label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent'},
{ label: '记忆(Memory)', key: 'memModelId', type: 'Memory' }, {label: '记忆(Memory)', key: 'memModelId', type: 'Memory'},
{ label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS' }, {label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS'},
], ],
modelOptions: {}, modelOptions: {},
templates: [], templates: [],
@@ -144,7 +181,7 @@ export default {
language: this.form.language, language: this.form.language,
sort: this.form.sort 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) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message: '配置保存成功', message: '配置保存成功',
@@ -164,7 +201,6 @@ export default {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
// 重置表单
this.form = { this.form = {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
@@ -187,10 +223,10 @@ export default {
showClose: true showClose: true
}) })
}).catch(() => { }).catch(() => {
}) });
}, },
fetchTemplates() { fetchTemplates() {
Api.agent.getAgentTemplate(({ data }) => { Api.agent.getAgentTemplate(({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.templates = data.data; this.templates = data.data;
} else { } else {
@@ -235,7 +271,7 @@ export default {
}; };
}, },
fetchAgentConfig(agentId) { fetchAgentConfig(agentId) {
Api.agent.getDeviceConfig(agentId, ({ data }) => { Api.agent.getDeviceConfig(agentId, ({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.form = { this.form = {
...this.form, ...this.form,
@@ -255,9 +291,8 @@ export default {
}); });
}, },
fetchModelOptions() { fetchModelOptions() {
// 为每个模型类型获取选项
this.models.forEach(model => { this.models.forEach(model => {
Api.model.getModelNames(model.type, '', ({ data }) => { Api.model.getModelNames(model.type, '', ({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.$set(this.modelOptions, model.type, data.data.map(item => ({ this.$set(this.modelOptions, model.type, data.data.map(item => ({
value: item.id, value: item.id,
@@ -274,7 +309,7 @@ export default {
this.voiceOptions = []; this.voiceOptions = [];
return; return;
} }
Api.model.getModelVoices(modelId, '', ({ data }) => { Api.model.getModelVoices(modelId, '', ({data}) => {
if (data.code === 0 && data.data) { if (data.code === 0 && data.data) {
this.voiceOptions = data.data.map(voice => ({ this.voiceOptions = data.data.map(voice => ({
value: voice.id, value: voice.id,
@@ -289,7 +324,6 @@ export default {
watch: { watch: {
'form.model.ttsModelId': { 'form.model.ttsModelId': {
handler(newVal, oldVal) { handler(newVal, oldVal) {
console.log('TTS模型变化:', newVal);
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
this.form.ttsVoiceId = ''; this.form.ttsVoiceId = '';
this.fetchVoiceOptions(newVal); this.fetchVoiceOptions(newVal);
@@ -325,60 +359,131 @@ export default {
min-height: 506px; min-height: 506px;
height: 100vh; height: 100vh;
display: flex; display: flex;
position: relative;
flex-direction: column; flex-direction: column;
background: linear-gradient(145deg, #e6eeff, #eff0ff); background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
background-size: cover; background-size: cover;
/* 确保背景图像覆盖整个元素 */
background-position: center;
/* 从顶部中心对齐 */
-webkit-background-size: cover; -webkit-background-size: cover;
/* 兼容老版本WebKit浏览器 */
-o-background-size: cover; -o-background-size: cover;
/* 兼容老版本Opera浏览器 */ overflow: hidden;
} }
.el-form-item ::v-deep .el-form-item__label { .operation-bar {
font-size: 10px !important; display: flex;
color: #3d4566 !important; justify-content: space-between;
font-weight: 400; align-items: center;
line-height: 22px; padding: 16px 24px;
padding-bottom: 2px;
} }
.select-field { .page-title {
width: 100%; font-size: 24px;
max-width: 720px; margin: 0;
border: 1px solid #e4e6ef; color: #2c3e50;
background: #f6f8fb;
border-radius: 8px;
height: 36px !important;
} }
.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; flex: 1;
height: 37px; display: flex;
border-radius: 20px; overflow: hidden;
border: 1px solid #e4e6ef; height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
} }
.clear-btn { .content-area {
width: 48px; flex: 1;
height: 19px; height: 100%;
background: #fd8383; min-width: 600px;
border-radius: 10px; overflow: auto;
line-height: 19px; background-color: white;
font-size: 11px; display: flex;
color: #fff; flex-direction: column;
cursor: pointer;
} }
.clear-text { .config-card {
color: #979db1; background: white;
font-size: 11px; border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
.config-header {
display: flex; display: flex;
align-items: center; 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; gap: 8px;
margin-left: 16px; flex-wrap: wrap;
} }
.template-item { .template-item {
@@ -399,47 +504,59 @@ export default {
background-color: #d0d8ff; background-color: #d0d8ff;
} }
.prompt-bottom { .action-bar {
margin-bottom: 4px;
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
padding: 0 16px; gap: 8px;
margin-top: 20px;
align-items: center; 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 { .save-btn {
border-radius: 18px;
background: #5778ff; background: #5778ff;
color: #fff; color: white;
border: none;
border-radius: 18px;
padding: 10px 20px;
} }
.reset-btn { .reset-btn {
border: 1px solid #adbdff;
background: #e6ebff; background: #e6ebff;
color: #5778ff; color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
} }
.textarea-box { .hint-text {
border: 1px solid #e4e6ef; display: flex;
border-radius: 8px; align-items: center;
background: #f6f8fb; 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>
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from loguru import logger from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
SERVER_VERSION = "0.3.4" SERVER_VERSION = "0.3.5"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
+21 -1
View File
@@ -116,6 +116,27 @@ class ConnectionHandler:
try: try:
# 获取并验证headers # 获取并验证headers
self.headers = dict(ws.request.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地址 # 获取客户端ip地址
self.client_ip = ws.remote_address[0] self.client_ip = ws.remote_address[0]
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
@@ -184,7 +205,6 @@ class ConnectionHandler:
self._initialize_models() self._initialize_models()
"""加载提示词""" """加载提示词"""
self.prompt = self.config["prompt"]
self.dialogue.put(Message(role="system", content=self.prompt)) self.dialogue.put(Message(role="system", content=self.prompt))
"""加载记忆""" """加载记忆"""
@@ -81,6 +81,8 @@ async def wakeupWordsResponse(conn):
"""唤醒词响应""" """唤醒词响应"""
wakeup_word = random.choice(WAKEUP_CONFIG["words"]) wakeup_word = random.choice(WAKEUP_CONFIG["words"])
result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word) 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) tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
if tts_file is not None and os.path.exists(tts_file): if tts_file is not None and os.path.exists(tts_file):
+27 -25
View File
@@ -144,33 +144,35 @@ class IotDescriptor:
self.methods = [] self.methods = []
# 根据描述创建属性 # 根据描述创建属性
for key, value in properties.items(): if properties is not None:
property_item = globals()[key] = {} for key, value in properties.items():
property_item["name"] = key property_item = globals()[key] = {}
property_item["description"] = value["description"] property_item["name"] = key
if value["type"] == "number": property_item["description"] = value["description"]
property_item["value"] = 0 if value["type"] == "number":
elif value["type"] == "boolean": property_item["value"] = 0
property_item["value"] = False elif value["type"] == "boolean":
else: property_item["value"] = False
property_item["value"] = "" else:
self.properties.append(property_item) property_item["value"] = ""
self.properties.append(property_item)
# 根据描述创建方法 # 根据描述创建方法
for key, value in methods.items(): if methods is not None:
method = globals()[key] = {} for key, value in methods.items():
method["description"] = value["description"] method = globals()[key] = {}
method["name"] = key method["description"] = value["description"]
for k, v in value["parameters"].items(): method["name"] = key
method[k] = {} for k, v in value["parameters"].items():
method[k]["description"] = v["description"] method[k] = {}
if v["type"] == "number": method[k]["description"] = v["description"]
method[k]["value"] = 0 if v["type"] == "number":
elif v["type"] == "boolean": method[k]["value"] = 0
method[k]["value"] = False elif v["type"] == "boolean":
else: method[k]["value"] = False
method[k]["value"] = "" else:
self.methods.append(method) method[k]["value"] = ""
self.methods.append(method)
def register_device_type(descriptor): def register_device_type(descriptor):
@@ -115,7 +115,7 @@ async def check_bind_device(conn):
digit = conn.bind_code[i] digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav" num_path = f"config/assets/bind_code/{digit}.wav"
num_packets, _ = conn.tts.audio_to_opus_data(num_path) num_packets, _ = conn.tts.audio_to_opus_data(num_path)
conn.audio_play_queue.put((num_packets, text, i + 1)) conn.audio_play_queue.put((num_packets, None, i + 1))
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
continue continue
@@ -3,7 +3,6 @@ import json
import asyncio import asyncio
import time import time
from core.utils.util import ( from core.utils.util import (
remove_punctuation_and_length,
get_string_no_punctuation_or_emoji, get_string_no_punctuation_or_emoji,
) )
@@ -4,9 +4,17 @@ import json
import re import re
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
import os import os
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py) # official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
from cozepy import COZE_CN_BASE_URL from cozepy import COZE_CN_BASE_URL
from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa from cozepy import (
Coze,
TokenAuth,
Message,
ChatStatus,
MessageContentType,
ChatEventType,
) # noqa
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -15,8 +23,8 @@ logger = setup_logging()
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, config):
self.personal_access_token = config.get("personal_access_token") self.personal_access_token = config.get("personal_access_token")
self.bot_id = config.get("bot_id") self.bot_id = str(config.get("bot_id"))
self.user_id = config.get("user_id") self.user_id = str(config.get("user_id"))
self.session_conversation_map = {} # 存储session_id和conversation_id的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
@@ -24,16 +32,13 @@ class LLMProvider(LLMProviderBase):
coze_api_base = COZE_CN_BASE_URL coze_api_base = COZE_CN_BASE_URL
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user") 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) coze = Coze(auth=TokenAuth(token=coze_api_token), base_url=coze_api_base)
conversation_id = self.session_conversation_map.get(session_id) conversation_id = self.session_conversation_map.get(session_id)
# 如果没有找到conversation_id,则创建新的对话 # 如果没有找到conversation_id,则创建新的对话
if not conversation_id: if not conversation_id:
conversation = coze.conversations.create( conversation = coze.conversations.create(messages=[])
messages=[
]
)
conversation_id = conversation.id conversation_id = conversation.id
self.session_conversation_map[session_id] = conversation_id # 更新映射 self.session_conversation_map[session_id] = conversation_id # 更新映射
@@ -47,4 +52,4 @@ class LLMProvider(LLMProviderBase):
): ):
if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA:
print(event.message.content, end="", flush=True) print(event.message.content, end="", flush=True)
yield event.message.content yield event.message.content
@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, conint, model_validator
from typing_extensions import Annotated from typing_extensions import Annotated
from datetime import datetime from datetime import datetime
from typing import Literal 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 core.providers.tts.base import TTSProviderBase
from config.logger import setup_logging from config.logger import setup_logging
@@ -86,8 +86,8 @@ class TTSProvider(TTSProviderBase):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.reference_id = config.get("reference_id") self.reference_id = config.get("reference_id")
self.reference_audio = config.get("reference_audio", []) self.reference_audio = parse_string_to_list(config.get("reference_audio"))
self.reference_text = config.get("reference_text", []) self.reference_text = parse_string_to_list(config.get("reference_text"))
self.format = config.get("format", "wav") self.format = config.get("format", "wav")
self.channels = int(config.get("channels", 1)) self.channels = int(config.get("channels", 1))
self.rate = int(config.get("rate", 44100)) 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.top_p = float(config.get("top_p", 0.7))
self.repetition_penalty = float(config.get("repetition_penalty", 1.2)) self.repetition_penalty = float(config.get("repetition_penalty", 1.2))
self.temperature = float(config.get("temperature", 0.7)) 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.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") self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts")
def generate_filename(self, extension=".wav"): def generate_filename(self, extension=".wav"):
@@ -6,6 +6,7 @@ import requests
from config.logger import setup_logging from config.logger import setup_logging
from datetime import datetime from datetime import datetime
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
from core.utils.util import parse_string_to_list
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -25,14 +26,33 @@ class TTSProvider(TTSProviderBase):
self.text_split_method = config.get("text_split_method", "cut0") self.text_split_method = config.get("text_split_method", "cut0")
self.batch_size = int(config.get("batch_size", 1)) self.batch_size = int(config.get("batch_size", 1))
self.batch_threshold = float(config.get("batch_threshold", 0.75)) 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.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.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.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"): def generate_filename(self, extension=".wav"):
return os.path.join( return os.path.join(
@@ -4,6 +4,7 @@ import requests
from config.logger import setup_logging from config.logger import setup_logging
from datetime import datetime from datetime import datetime
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
from core.utils.util import parse_string_to_list
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -22,9 +23,9 @@ class TTSProvider(TTSProviderBase):
self.temperature = float(config.get("temperature", 1.0)) self.temperature = float(config.get("temperature", 1.0))
self.cut_punc = config.get("cut_punc", "") self.cut_punc = config.get("cut_punc", "")
self.speed = float(config.get("speed", 1.0)) 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.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"): def generate_filename(self, extension=".wav"):
return os.path.join( return os.path.join(
@@ -4,6 +4,7 @@ import json
import requests import requests
from datetime import datetime from datetime import datetime
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
from core.utils.util import parse_string_to_list
class TTSProvider(TTSProviderBase): class TTSProvider(TTSProviderBase):
@@ -40,7 +41,7 @@ class TTSProvider(TTSProviderBase):
**config.get("pronunciation_dict", {}), **config.get("pronunciation_dict", {}),
} }
self.audio_setting = {**defult_audio_setting, **config.get("audio_setting", {})} 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: if self.voice_id:
self.voice_setting["voice_id"] = self.voice_id self.voice_setting["voice_id"] = self.voice_id
@@ -22,7 +22,7 @@ class TTSProvider(TTSProviderBase):
self.to_lang = config.get("to_lang") self.to_lang = config.get("to_lang")
self.volume_change_dB = int(config.get("volume_change_dB", 0)) self.volume_change_dB = int(config.get("volume_change_dB", 0))
self.speed_factor = int(config.get("speed_factor", 1)) 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.output_file = config.get("output_dir")
self.pitch_factor = int(config.get("pitch_factor", 0)) self.pitch_factor = int(config.get("pitch_factor", 0))
self.format = config.get("format", "mp3") self.format = config.get("format", "mp3")
+20 -2
View File
@@ -163,6 +163,24 @@ def check_model_key(modelType, modelKey):
return True 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(): def check_ffmpeg_installed():
ffmpeg_installed = False ffmpeg_installed = False
try: try:
@@ -231,7 +249,7 @@ def initialize_modules(
modules["tts"] = tts.create_instance( modules["tts"] = tts.create_instance(
tts_type, tts_type,
config["TTS"][select_tts_module], config["TTS"][select_tts_module],
bool(config.get("delete_audio", True)), str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
) )
logger.bind(tag=TAG).info(f"初始化组件: tts成功 {select_tts_module}") logger.bind(tag=TAG).info(f"初始化组件: tts成功 {select_tts_module}")
@@ -302,7 +320,7 @@ def initialize_modules(
modules["asr"] = asr.create_instance( modules["asr"] = asr.create_instance(
asr_type, asr_type,
config["ASR"][select_asr_module], config["ASR"][select_asr_module],
bool(config.get("delete_audio", True)), str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
) )
logger.bind(tag=TAG).info(f"初始化组件: asr成功 {select_asr_module}") logger.bind(tag=TAG).info(f"初始化组件: asr成功 {select_asr_module}")
+373 -87
View File
@@ -42,6 +42,50 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; 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 { .control-panel {
@@ -70,7 +114,8 @@
cursor: not-allowed; cursor: not-allowed;
} }
#serverUrl { #serverUrl,
#otaUrl {
flex-grow: 1; flex-grow: 1;
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid #ddd;
@@ -316,6 +361,76 @@
gap: 20px; gap: 20px;
margin-top: 10px; 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> </style>
</head> </head>
@@ -326,17 +441,56 @@
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);"> <div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
正在加载Opus库...</div> 正在加载Opus库...</div>
<!-- 添加配置面板 -->
<div class="section"> <div class="section">
<h2>WebSocket连接 <span id="connectionStatus" class="status">未连接</span></h2> <h2>
<div class="control-panel"> 设备配置
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/" placeholder="WebSocket服务器地址"> <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="connectButton">连接</button>
<button id="authTestButton">测试认证</button> <button id="authTestButton">测试认证</button>
</div> </div>
</div> </div>
<div class="section"> <div class="section">
<h2>消息发送</h2>
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="text">文本消息</button> <button class="tab active" data-tab="text">文本消息</button>
<button class="tab" data-tab="voice">语音消息</button> <button class="tab" data-tab="voice">语音消息</button>
@@ -554,16 +708,16 @@
// 开始音频缓冲过程 // 开始音频缓冲过程
function startAudioBuffering() { function startAudioBuffering() {
if (isAudioBuffering || isAudioPlaying) return; if (isAudioBuffering || isAudioPlaying) return;
isAudioBuffering = true; isAudioBuffering = true;
log("开始音频缓冲...", 'info'); log("开始音频缓冲...", 'info');
// 先尝试初始化解码器,以便在播放时已准备好 // 先尝试初始化解码器,以便在播放时已准备好
initOpusDecoder().catch(error => { initOpusDecoder().catch(error => {
log(`预初始化Opus解码器失败: ${error.message}`, 'warning'); log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
// 继续缓冲,我们会在播放时再次尝试初始化 // 继续缓冲,我们会在播放时再次尝试初始化
}); });
// 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放 // 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放
setTimeout(() => { setTimeout(() => {
if (isAudioBuffering && audioBufferQueue.length > 0) { if (isAudioBuffering && audioBufferQueue.length > 0) {
@@ -571,14 +725,14 @@
playBufferedAudio(); playBufferedAudio();
} }
}, 300); // 300ms超时 }, 300); // 300ms超时
// 监控缓冲进度 // 监控缓冲进度
const bufferCheckInterval = setInterval(() => { const bufferCheckInterval = setInterval(() => {
if (!isAudioBuffering) { if (!isAudioBuffering) {
clearInterval(bufferCheckInterval); clearInterval(bufferCheckInterval);
return; return;
} }
// 当累积了足够的音频包,开始播放 // 当累积了足够的音频包,开始播放
if (audioBufferQueue.length >= BUFFER_THRESHOLD) { if (audioBufferQueue.length >= BUFFER_THRESHOLD) {
clearInterval(bufferCheckInterval); clearInterval(bufferCheckInterval);
@@ -591,10 +745,10 @@
// 播放已缓冲的音频 // 播放已缓冲的音频
function playBufferedAudio() { function playBufferedAudio() {
if (isAudioPlaying || audioBufferQueue.length === 0) return; if (isAudioPlaying || audioBufferQueue.length === 0) return;
isAudioPlaying = true; isAudioPlaying = true;
isAudioBuffering = false; isAudioBuffering = false;
// 确保Opus解码器已初始化 // 确保Opus解码器已初始化
const initDecoderAndPlay = async () => { const initDecoderAndPlay = async () => {
try { try {
@@ -605,7 +759,7 @@
}); });
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug'); log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
} }
// 确保解码器已初始化 // 确保解码器已初始化
if (!opusDecoder) { if (!opusDecoder) {
log('初始化Opus解码器...', 'info'); log('初始化Opus解码器...', 'info');
@@ -621,7 +775,7 @@
return; return;
} }
} }
// 创建流式播放上下文 // 创建流式播放上下文
if (!streamingContext) { if (!streamingContext) {
streamingContext = { streamingContext = {
@@ -631,16 +785,16 @@
source: null, // 当前音频源 source: null, // 当前音频源
totalSamples: 0, // 累积的总样本数 totalSamples: 0, // 累积的总样本数
lastPlayTime: 0, // 上次播放的时间戳 lastPlayTime: 0, // 上次播放的时间戳
// 将Opus数据解码为PCM // 将Opus数据解码为PCM
decodeOpusFrames: async function(opusFrames) { decodeOpusFrames: async function (opusFrames) {
if (!opusDecoder) { if (!opusDecoder) {
log('Opus解码器未初始化,无法解码', 'error'); log('Opus解码器未初始化,无法解码', 'error');
return; return;
} }
let decodedSamples = []; let decodedSamples = [];
for (const frame of opusFrames) { for (const frame of opusFrames) {
try { try {
// 使用Opus解码器解码 // 使用Opus解码器解码
@@ -654,12 +808,12 @@
log("Opus解码失败: " + error.message, 'error'); log("Opus解码失败: " + error.message, 'error');
} }
} }
if (decodedSamples.length > 0) { if (decodedSamples.length > 0) {
// 添加到解码队列 // 添加到解码队列
this.queue.push(...decodedSamples); this.queue.push(...decodedSamples);
this.totalSamples += decodedSamples.length; this.totalSamples += decodedSamples.length;
// 如果累积了至少0.2秒的音频,开始播放 // 如果累积了至少0.2秒的音频,开始播放
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION; const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION;
if (!this.playing && this.queue.length >= minSamples) { if (!this.playing && this.queue.length >= minSamples) {
@@ -669,50 +823,50 @@
log('没有成功解码的样本', 'warning'); log('没有成功解码的样本', 'warning');
} }
}, },
// 开始播放音频 // 开始播放音频
startPlaying: function() { startPlaying: function () {
if (this.playing || this.queue.length === 0) return; if (this.playing || this.queue.length === 0) return;
this.playing = true; this.playing = true;
// 创建新的音频缓冲区 // 创建新的音频缓冲区
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒 const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒
const currentSamples = this.queue.splice(0, minPlaySamples); const currentSamples = this.queue.splice(0, minPlaySamples);
const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE); const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0); audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
// 创建音频源 // 创建音频源
this.source = audioContext.createBufferSource(); this.source = audioContext.createBufferSource();
this.source.buffer = audioBuffer; this.source.buffer = audioBuffer;
// 创建增益节点用于平滑过渡 // 创建增益节点用于平滑过渡
const gainNode = audioContext.createGain(); const gainNode = audioContext.createGain();
// 应用淡入淡出效果避免爆音 // 应用淡入淡出效果避免爆音
const fadeDuration = 0.02; // 20毫秒 const fadeDuration = 0.02; // 20毫秒
gainNode.gain.setValueAtTime(0, audioContext.currentTime); gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration); gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
const duration = audioBuffer.duration; const duration = audioBuffer.duration;
if (duration > fadeDuration * 2) { if (duration > fadeDuration * 2) {
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration); gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration); gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
} }
// 连接节点并开始播放 // 连接节点并开始播放
this.source.connect(gainNode); this.source.connect(gainNode);
gainNode.connect(audioContext.destination); gainNode.connect(audioContext.destination);
this.lastPlayTime = audioContext.currentTime; this.lastPlayTime = audioContext.currentTime;
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)}`, 'info'); log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)}`, 'info');
// 播放结束后的处理 // 播放结束后的处理
this.source.onended = () => { this.source.onended = () => {
this.source = null; this.source = null;
this.playing = false; this.playing = false;
// 如果队列中还有数据或者缓冲区有新数据,继续播放 // 如果队列中还有数据或者缓冲区有新数据,继续播放
if (this.queue.length > 0) { if (this.queue.length > 0) {
setTimeout(() => this.startPlaying(), 10); setTimeout(() => this.startPlaying(), 10);
@@ -743,26 +897,26 @@
}, 500); // 500ms超时 }, 500); // 500ms超时
} }
}; };
this.source.start(); this.source.start();
} }
}; };
} }
// 开始处理缓冲的数据 // 开始处理缓冲的数据
const frames = [...audioBufferQueue]; const frames = [...audioBufferQueue];
audioBufferQueue = []; // 清空缓冲队列 audioBufferQueue = []; // 清空缓冲队列
// 解码并播放 // 解码并播放
await streamingContext.decodeOpusFrames(frames); await streamingContext.decodeOpusFrames(frames);
} catch (error) { } catch (error) {
log(`播放已缓冲的音频出错: ${error.message}`, 'error'); log(`播放已缓冲的音频出错: ${error.message}`, 'error');
isAudioPlaying = false; isAudioPlaying = false;
streamingContext = null; streamingContext = null;
} }
}; };
// 执行初始化和播放 // 执行初始化和播放
initDecoderAndPlay(); initDecoderAndPlay();
} }
@@ -780,7 +934,7 @@
// 初始化Opus解码器 - 确保完全初始化完成后才返回 // 初始化Opus解码器 - 确保完全初始化完成后才返回
async function initOpusDecoder() { async function initOpusDecoder() {
if (opusDecoder) return opusDecoder; // 已经初始化 if (opusDecoder) return opusDecoder; // 已经初始化
try { try {
// 检查ModuleInstance是否存在 // 检查ModuleInstance是否存在
if (typeof window.ModuleInstance === 'undefined') { if (typeof window.ModuleInstance === 'undefined') {
@@ -792,9 +946,9 @@
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在'); throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
} }
} }
const mod = window.ModuleInstance; const mod = window.ModuleInstance;
// 创建解码器对象 // 创建解码器对象
opusDecoder = { opusDecoder = {
channels: CHANNELS, channels: CHANNELS,
@@ -802,55 +956,55 @@
frameSize: FRAME_SIZE, frameSize: FRAME_SIZE,
module: mod, module: mod,
decoderPtr: null, // 初始为null decoderPtr: null, // 初始为null
// 初始化解码器 // 初始化解码器
init: function() { init: function () {
if (this.decoderPtr) return true; // 已经初始化 if (this.decoderPtr) return true; // 已经初始化
// 获取解码器大小 // 获取解码器大小
const decoderSize = mod._opus_decoder_get_size(this.channels); const decoderSize = mod._opus_decoder_get_size(this.channels);
log(`Opus解码器大小: ${decoderSize}字节`, 'debug'); log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
// 分配内存 // 分配内存
this.decoderPtr = mod._malloc(decoderSize); this.decoderPtr = mod._malloc(decoderSize);
if (!this.decoderPtr) { if (!this.decoderPtr) {
throw new Error("无法分配解码器内存"); throw new Error("无法分配解码器内存");
} }
// 初始化解码器 // 初始化解码器
const err = mod._opus_decoder_init( const err = mod._opus_decoder_init(
this.decoderPtr, this.decoderPtr,
this.rate, this.rate,
this.channels this.channels
); );
if (err < 0) { if (err < 0) {
this.destroy(); // 清理资源 this.destroy(); // 清理资源
throw new Error(`Opus解码器初始化失败: ${err}`); throw new Error(`Opus解码器初始化失败: ${err}`);
} }
log("Opus解码器初始化成功", 'success'); log("Opus解码器初始化成功", 'success');
return true; return true;
}, },
// 解码方法 // 解码方法
decode: function(opusData) { decode: function (opusData) {
if (!this.decoderPtr) { if (!this.decoderPtr) {
if (!this.init()) { if (!this.init()) {
throw new Error("解码器未初始化且无法初始化"); throw new Error("解码器未初始化且无法初始化");
} }
} }
try { try {
const mod = this.module; const mod = this.module;
// 为Opus数据分配内存 // 为Opus数据分配内存
const opusPtr = mod._malloc(opusData.length); const opusPtr = mod._malloc(opusData.length);
mod.HEAPU8.set(opusData, opusPtr); mod.HEAPU8.set(opusData, opusPtr);
// 为PCM输出分配内存 // 为PCM输出分配内存
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节 const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
// 解码 // 解码
const decodedSamples = mod._opus_decode( const decodedSamples = mod._opus_decode(
this.decoderPtr, this.decoderPtr,
@@ -860,46 +1014,46 @@
this.frameSize, this.frameSize,
0 // 不使用FEC 0 // 不使用FEC
); );
if (decodedSamples < 0) { if (decodedSamples < 0) {
mod._free(opusPtr); mod._free(opusPtr);
mod._free(pcmPtr); mod._free(pcmPtr);
throw new Error(`Opus解码失败: ${decodedSamples}`); throw new Error(`Opus解码失败: ${decodedSamples}`);
} }
// 复制解码后的数据 // 复制解码后的数据
const decodedData = new Int16Array(decodedSamples); const decodedData = new Int16Array(decodedSamples);
for (let i = 0; i < decodedSamples; i++) { for (let i = 0; i < decodedSamples; i++) {
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i]; decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
} }
// 释放内存 // 释放内存
mod._free(opusPtr); mod._free(opusPtr);
mod._free(pcmPtr); mod._free(pcmPtr);
return decodedData; return decodedData;
} catch (error) { } catch (error) {
log(`Opus解码错误: ${error.message}`, 'error'); log(`Opus解码错误: ${error.message}`, 'error');
return new Int16Array(0); return new Int16Array(0);
} }
}, },
// 销毁方法 // 销毁方法
destroy: function() { destroy: function () {
if (this.decoderPtr) { if (this.decoderPtr) {
this.module._free(this.decoderPtr); this.module._free(this.decoderPtr);
this.decoderPtr = null; this.decoderPtr = null;
} }
} }
}; };
// 初始化解码器 // 初始化解码器
if (!opusDecoder.init()) { if (!opusDecoder.init()) {
throw new Error("Opus解码器初始化失败"); throw new Error("Opus解码器初始化失败");
} }
return opusDecoder; return opusDecoder;
} catch (error) { } catch (error) {
log(`Opus解码器初始化失败: ${error.message}`, 'error'); log(`Opus解码器初始化失败: ${error.message}`, 'error');
opusDecoder = null; // 重置为null,以便下次重试 opusDecoder = null; // 重置为null,以便下次重试
@@ -1132,25 +1286,100 @@
} }
// 连接WebSocket服务器 // 连接WebSocket服务器
function connectToServer() { async function connectToServer() {
const url = serverUrlInput.value.trim(); const url = serverUrlInput.value.trim();
if (url === '') return; if (url === '') return;
try { try {
// 获取并验证配置
const config = getConfig();
if (!validateConfig(config)) {
return;
}
// 检查URL格式 // 检查URL格式
if (!url.startsWith('ws://') && !url.startsWith('wss://')) { if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
log('URL格式错误,必须以ws://或wss://开头', 'error'); log('URL格式错误,必须以ws://或wss://开头', 'error');
return; 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实现以添加认证头信息
// 注意:浏览器原生WebSocket不支持自定义头,需要通过服务器添加一个代理层
// 但我们可以通过URL参数模拟认证信息
let connUrl = new URL(url); let connUrl = new URL(url);
// 添加认证参数 // 添加认证参数
connUrl.searchParams.append('device_id', 'web_test_device'); connUrl.searchParams.append('device-id', config.deviceId);
connUrl.searchParams.append('device_mac', '00:11:22:33:44:55'); connUrl.searchParams.append('client-id', config.clientId);
log(`正在连接: ${connUrl.toString()}`, 'info'); log(`正在连接: ${connUrl.toString()}`, 'info');
websocket = new WebSocket(connUrl.toString()); websocket = new WebSocket(connUrl.toString());
@@ -1160,7 +1389,7 @@
websocket.onopen = async () => { websocket.onopen = async () => {
log(`已连接到服务器: ${url}`, 'success'); log(`已连接到服务器: ${url}`, 'success');
connectionStatus.textContent = '已连接'; connectionStatus.textContent = 'ws已连接';
connectionStatus.style.color = 'green'; connectionStatus.style.color = 'green';
// 连接成功后发送hello消息 // 连接成功后发送hello消息
@@ -1181,7 +1410,7 @@
websocket.onclose = () => { websocket.onclose = () => {
log('已断开连接', 'info'); log('已断开连接', 'info');
connectionStatus.textContent = '已断开'; connectionStatus.textContent = 'ws已断开';
connectionStatus.style.color = 'red'; connectionStatus.style.color = 'red';
connectButton.textContent = '连接'; connectButton.textContent = '连接';
@@ -1196,7 +1425,7 @@
websocket.onerror = (error) => { websocket.onerror = (error) => {
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error'); log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
connectionStatus.textContent = '连接错误'; connectionStatus.textContent = 'ws未连接';
connectionStatus.style.color = 'red'; connectionStatus.style.color = 'red';
}; };
@@ -1262,11 +1491,11 @@
} }
}; };
connectionStatus.textContent = '正在连接...'; connectionStatus.textContent = 'ws未连接';
connectionStatus.style.color = 'orange'; connectionStatus.style.color = 'orange';
} catch (error) { } catch (error) {
log(`连接错误: ${error.message}`, 'error'); log(`连接错误: ${error.message}`, 'error');
connectionStatus.textContent = '连接失败'; connectionStatus.textContent = 'ws未连接';
} }
} }
@@ -1275,13 +1504,15 @@
if (!websocket || websocket.readyState !== WebSocket.OPEN) return; if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
try { try {
const config = getConfig();
// 设置设备信息 // 设置设备信息
const helloMessage = { const helloMessage = {
type: 'hello', type: 'hello',
device_id: 'web_test_device', device_id: config.deviceId,
device_name: 'Web测试设备', device_name: config.deviceName,
device_mac: '00:11:22:33:44:55', device_mac: config.deviceMac,
token: 'your-token1' // 使用config.yaml中配置的token token: config.token
}; };
log('发送hello握手消息', 'info'); log('发送hello握手消息', 'info');
@@ -1356,6 +1587,34 @@
connectButton.addEventListener('click', connectToServer); connectButton.addEventListener('click', connectToServer);
document.getElementById('authTestButton').addEventListener('click', testAuthentication); 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'); const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => { tabs.forEach(tab => {
@@ -1390,12 +1649,14 @@
async function testAuthentication() { async function testAuthentication() {
log('开始测试认证...', 'info'); log('开始测试认证...', 'info');
const config = getConfig();
// 显示服务器配置 // 显示服务器配置
log('-------- 服务器认证配置检查 --------', 'info'); log('-------- 服务器认证配置检查 --------', 'info');
log('请确认config.yaml中的auth配置:', 'info'); log('请确认config.yaml中的auth配置:', 'info');
log('1. server.auth.enabled 为 false 或服务器已正确配置认证', 'info'); log('1. server.auth.enabled 为 false 或服务器已正确配置认证', 'info');
log('2. 如果启用了认证,请确认使用了正确的token', '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(); const serverUrl = serverUrlInput.value.trim();
if (!serverUrl) { if (!serverUrl) {
@@ -1436,9 +1697,9 @@
log('测试2: 尝试带token参数连接...', 'info'); log('测试2: 尝试带token参数连接...', 'info');
let url = new URL(serverUrl); let url = new URL(serverUrl);
url.searchParams.append('token', 'your-token1'); url.searchParams.append('token', config.token);
url.searchParams.append('device_id', 'web_test_device'); url.searchParams.append('device_id', config.deviceId);
url.searchParams.append('device_mac', '00:11:22:33:44:55'); url.searchParams.append('device_mac', config.deviceMac);
const ws2 = new WebSocket(url.toString()); const ws2 = new WebSocket(url.toString());
@@ -1448,9 +1709,9 @@
// 尝试发送hello消息 // 尝试发送hello消息
const helloMsg = { const helloMsg = {
type: 'hello', type: 'hello',
device_id: 'web_test_device', device_id: config.deviceId,
device_mac: '00:11:22:33:44:55', device_mac: config.deviceMac,
token: 'your-token1' token: config.token
}; };
ws2.send(JSON.stringify(helloMsg)); ws2.send(JSON.stringify(helloMsg));
@@ -2092,19 +2353,19 @@
if (opusData.length > 0) { if (opusData.length > 0) {
// 将数据添加到缓冲队列 // 将数据添加到缓冲队列
audioBufferQueue.push(opusData); audioBufferQueue.push(opusData);
// 如果收到的是第一个音频包,开始缓冲过程 // 如果收到的是第一个音频包,开始缓冲过程
if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) { if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) {
startAudioBuffering(); startAudioBuffering();
} }
} else { } else {
log('收到空音频数据帧,可能是结束标志', 'warning'); log('收到空音频数据帧,可能是结束标志', 'warning');
// 如果缓冲队列中有数据且没有在播放,立即开始播放 // 如果缓冲队列中有数据且没有在播放,立即开始播放
if (audioBufferQueue.length > 0 && !isAudioPlaying) { if (audioBufferQueue.length > 0 && !isAudioPlaying) {
playBufferedAudio(); playBufferedAudio();
} }
// 如果正在播放,发送结束信号 // 如果正在播放,发送结束信号
if (isAudioPlaying && streamingContext) { if (isAudioPlaying && streamingContext) {
streamingContext.endOfStream = true; streamingContext.endOfStream = true;
@@ -2115,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(); initApp();
</script> </script>
</body> </body>