Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12d3df073 | ||
|
|
82b1548ae0 | ||
|
|
afd1d2ae6a | ||
|
|
fd59aff4d0 | ||
|
|
59133b9d8d | ||
|
|
cca42309c4 | ||
|
|
510ee1b282 | ||
|
|
c4d3020a1d | ||
|
|
f3af30ae53 | ||
|
|
6d3f270a8f | ||
|
|
86621661df | ||
|
|
d668917694 | ||
|
|
8bc1d09c4a | ||
|
|
76b90c179b | ||
|
|
7c389a0641 | ||
|
|
4241120853 | ||
|
|
5998fc3edd | ||
|
|
392f8a1ce0 | ||
|
|
04203c4103 | ||
|
|
188cd7f023 | ||
|
|
8d17f5bd3c | ||
|
|
9d39880e9d | ||
|
|
f27e213d13 | ||
|
|
69ecb2c503 | ||
|
|
0585a0732f | ||
|
|
fab4adb07e | ||
|
|
5c3ae278be | ||
|
|
19a067f4e8 | ||
|
|
a7b5e0df6c | ||
|
|
83e9725d02 | ||
|
|
086dea8594 | ||
|
|
d3c8e4badb | ||
|
|
665318164e | ||
|
|
44f8254b43 | ||
|
|
e085fb1b10 | ||
|
|
48ed5d5c9b | ||
|
|
20c1f0576b | ||
|
|
fef8cece90 | ||
|
|
f549f26541 | ||
|
|
f20c17f27d | ||
|
|
fd188f6f65 | ||
|
|
ee3808dd2f | ||
|
|
d2d97149fc |
@@ -75,6 +75,7 @@ docs/_build/
|
|||||||
# PyBuilder
|
# PyBuilder
|
||||||
.pybuilder/
|
.pybuilder/
|
||||||
target/
|
target/
|
||||||
|
*.pid
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
@@ -106,7 +107,6 @@ celerybeat.pid
|
|||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -150,10 +150,12 @@ main/manager-web/node_modules
|
|||||||
.config.yaml
|
.config.yaml
|
||||||
.secrets.yaml
|
.secrets.yaml
|
||||||
.private_config.yaml
|
.private_config.yaml
|
||||||
.env.development
|
|
||||||
|
|
||||||
# model files
|
# model files
|
||||||
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
|
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
|
||||||
main/xiaozhi-server/models/sherpa-onnx*
|
main/xiaozhi-server/models/sherpa-onnx*
|
||||||
my_wakeup_words.mp3
|
my_wakeup_words.mp3
|
||||||
main/manager-api/.vscode
|
main/manager-api/.vscode
|
||||||
|
|
||||||
|
# Ignore webpack cache directory
|
||||||
|
main/manager-web/.webpack_cache/
|
||||||
@@ -81,15 +81,16 @@ src/main/java/xiaozhi/AdminApplication.java
|
|||||||
执行以下命令生产jar包
|
执行以下命令生产jar包
|
||||||
|
|
||||||
```
|
```
|
||||||
mvn install
|
mvn clean install
|
||||||
```
|
```
|
||||||
|
|
||||||
把jar包放在服务器上,执行
|
把jar包放在服务器上,执行
|
||||||
|
|
||||||
```
|
```
|
||||||
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev >/dev/null &
|
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.activate=dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
# 接口文档
|
# 接口文档
|
||||||
启动后打开:http://localhost:8002/xiaozhi-esp32-api/doc.html
|
启动后打开:http://localhost:8002/xiaozhi-esp32-api/doc.html
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ public class Oauth2Filter extends AuthenticatingFilter {
|
|||||||
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
|
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
|
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
|
||||||
try {
|
try {
|
||||||
// 处理登录失败的异常
|
|
||||||
logger.error("onLoginFailure:登录失败!", e);
|
|
||||||
Throwable throwable = e.getCause() == null ? e : e.getCause();
|
Throwable throwable = e.getCause() == null ? e : e.getCause();
|
||||||
Result<Void> r = new Result<Void>().error(ErrorCode.UNAUTHORIZED, throwable.getMessage());
|
Result<Void> r = new Result<Void>().error(ErrorCode.UNAUTHORIZED, throwable.getMessage());
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class AdminController {
|
|||||||
AdminPageUserDTO dto = new AdminPageUserDTO();
|
AdminPageUserDTO dto = new AdminPageUserDTO();
|
||||||
dto.setMobile((String) params.get("mobile"));
|
dto.setMobile((String) params.get("mobile"));
|
||||||
dto.setLimit((String) params.get(Constant.LIMIT));
|
dto.setLimit((String) params.get(Constant.LIMIT));
|
||||||
dto.setPage((String) params.get("pages"));
|
dto.setPage((String) params.get(Constant.PAGE));
|
||||||
|
|
||||||
ValidatorUtils.validateEntity(dto);
|
ValidatorUtils.validateEntity(dto);
|
||||||
PageData<AdminPageUserVO> page = sysUserService.page(dto);
|
PageData<AdminPageUserVO> page = sysUserService.page(dto);
|
||||||
|
|||||||
@@ -1,35 +1,139 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<configuration>
|
<configuration>
|
||||||
<include resource="org/springframework/boot/logging/logback/base.xml" />
|
<!-- 启用JansiConsoleAppender以确保控制台输出有颜色 -->
|
||||||
<logger name="org.springframework.web" level="INFO"/>
|
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
|
||||||
<logger name="org.springboot.sample" level="TRACE" />
|
|
||||||
|
<!-- 确保日志目录存在 -->
|
||||||
<!-- 开发、测试环境 -->
|
<timestamp key="bySecond" datePattern="yyyyMMdd'T'HHmmss"/>
|
||||||
|
|
||||||
|
<!-- 定义日志文件存储位置 -->
|
||||||
|
<property name="LOG_HOME" value="./logs" />
|
||||||
|
|
||||||
|
<!-- 使用自定义的初始化监听器确保日志目录存在 -->
|
||||||
|
<define name="LOGBACK_DIR_CHECK" class="ch.qos.logback.core.property.FileExistsPropertyDefiner">
|
||||||
|
<path>${LOG_HOME}</path>
|
||||||
|
<createIfMissing>true</createIfMissing>
|
||||||
|
</define>
|
||||||
|
|
||||||
|
<!-- 引入Spring Boot默认配置 -->
|
||||||
|
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||||
|
|
||||||
|
<!-- 控制台输出配置 -->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %msg%n</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 文件输出配置 - 所有日志 -->
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_HOME}/xiaozhi-esp32-api.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>10MB</maxFileSize>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>2GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 文件输出配置 - 仅错误日志 -->
|
||||||
|
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_HOME}/error.log</file>
|
||||||
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||||
|
<level>ERROR</level>
|
||||||
|
</filter>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>10MB</maxFileSize>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>1GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 开发环境配置 -->
|
||||||
<springProfile name="dev">
|
<springProfile name="dev">
|
||||||
<logger name="org.springframework.web" level="INFO"/>
|
<root level="INFO">
|
||||||
<logger name="org.springboot.sample" level="INFO" />
|
<appender-ref ref="CONSOLE" />
|
||||||
|
<!-- 开发环境也记录日志文件 -->
|
||||||
|
<appender-ref ref="FILE" />
|
||||||
|
<appender-ref ref="ERROR_FILE" />
|
||||||
|
</root>
|
||||||
<logger name="xiaozhi" level="DEBUG" />
|
<logger name="xiaozhi" level="DEBUG" />
|
||||||
|
<logger name="org.springframework.web" level="INFO" />
|
||||||
|
<logger name="org.springboot.sample" level="INFO" />
|
||||||
</springProfile>
|
</springProfile>
|
||||||
|
|
||||||
<!-- 生产环境 -->
|
<!-- 测试环境配置 -->
|
||||||
|
<springProfile name="test">
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE" />
|
||||||
|
<appender-ref ref="FILE" />
|
||||||
|
<appender-ref ref="ERROR_FILE" />
|
||||||
|
</root>
|
||||||
|
<logger name="xiaozhi" level="INFO" />
|
||||||
|
<logger name="org.springframework.web" level="INFO" />
|
||||||
|
<logger name="org.springboot.sample" level="INFO" />
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<!-- 生产环境配置 - 合并两个版本的最佳实践 -->
|
||||||
<springProfile name="prod">
|
<springProfile name="prod">
|
||||||
<appender name="LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
<!-- 使用本地日志路径 -->
|
||||||
|
<appender name="PROD_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_HOME}/xiaozhi-esp32-api.log</file>
|
||||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
<!--日志文件输出的文件名-->
|
<fileNamePattern>${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
<FileNamePattern>/home/system/logs/admin.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
|
<maxFileSize>10MB</maxFileSize>
|
||||||
<!--日志大小-->
|
<maxHistory>30</maxHistory>
|
||||||
<maxFileSize>1MB</maxFileSize>
|
<totalSizeCap>2GB</totalSizeCap>
|
||||||
<!--日志文件保留天数-->
|
|
||||||
<maxHistory>7</maxHistory>
|
|
||||||
</rollingPolicy>
|
</rollingPolicy>
|
||||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
<encoder>
|
||||||
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
|
|
||||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
<logger name="org.springframework.web" level="ERROR"/>
|
|
||||||
<logger name="org.springboot.sample" level="ERROR"/>
|
<!-- 生产环境错误日志 -->
|
||||||
<logger name="xiaozhi" level="INFO"/>
|
<appender name="PROD_ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_HOME}/error.log</file>
|
||||||
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||||
|
<level>ERROR</level>
|
||||||
|
</filter>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>10MB</maxFileSize>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>1GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 生产环境根日志配置 -->
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="PROD_FILE" />
|
||||||
|
<appender-ref ref="PROD_ERROR_FILE" />
|
||||||
|
<appender-ref ref="CONSOLE" />
|
||||||
|
</root>
|
||||||
|
|
||||||
|
<!-- 特定包的日志级别 -->
|
||||||
|
<logger name="xiaozhi" level="INFO" />
|
||||||
|
<logger name="org.springframework.web" level="ERROR" />
|
||||||
|
<logger name="org.springboot.sample" level="ERROR" />
|
||||||
</springProfile>
|
</springProfile>
|
||||||
|
|
||||||
</configuration>
|
<!-- 确保日志目录存在 -->
|
||||||
|
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
|
||||||
|
<resetJUL>true</resetJUL>
|
||||||
|
</contextListener>
|
||||||
|
</configuration>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VUE_APP_TITLE=小智-智控台
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmmirror.com/
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
开发使用代码编辑器,导入项目时,选择`manager-web`文件夹作为项目目录
|
开发使用代码编辑器,导入项目时,选择`manager-web`文件夹作为项目目录
|
||||||
|
|
||||||
参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
|
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['@vue/cli-plugin-babel/preset', {
|
||||||
|
useBuiltIns: 'usage',
|
||||||
|
corejs: 3
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'@babel/plugin-syntax-dynamic-import', // 确保支持动态导入 (Lazy Loading)
|
||||||
|
'@babel/plugin-transform-runtime'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,9 +4,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"build": "vue-cli-service build"
|
"build": "vue-cli-service build",
|
||||||
|
"analyze": "cross-env ANALYZE=true vue-cli-service build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"core-js": "^3.41.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"element-ui": "^2.15.14",
|
"element-ui": "^2.15.14",
|
||||||
"flyio": "^0.6.14",
|
"flyio": "^0.6.14",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
@@ -19,16 +22,25 @@
|
|||||||
"xiaozhi": "file:"
|
"xiaozhi": "file:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||||
|
"@babel/plugin-transform-runtime": "^7.26.10",
|
||||||
"@vue/cli-plugin-router": "~5.0.0",
|
"@vue/cli-plugin-router": "~5.0.0",
|
||||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||||
"@vue/cli-service": "~5.0.0",
|
"@vue/cli-service": "~5.0.0",
|
||||||
|
"compression-webpack-plugin": "^11.1.0",
|
||||||
"sass": "^1.32.7",
|
"sass": "^1.32.7",
|
||||||
"sass-loader": "^12.0.0",
|
"sass-loader": "^12.0.0",
|
||||||
"vue-template-compiler": "^2.6.14"
|
"vue-template-compiler": "^2.6.14",
|
||||||
|
"webpack-bundle-analyzer": "^4.10.2"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"> 1%",
|
||||||
"last 2 versions",
|
"last 2 versions",
|
||||||
"not dead"
|
"not dead"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css",
|
||||||
|
"*.scss",
|
||||||
|
"*.vue"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
<head>
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<title>小智-智控台</title>
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
</head>
|
<title>
|
||||||
<body>
|
<%= process.env.VUE_APP_TITLE %>
|
||||||
<noscript>
|
</title>
|
||||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<% if (htmlWebpackPlugin.options.cdn) { %>
|
||||||
</noscript>
|
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
|
||||||
<div id="app"></div>
|
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
|
||||||
<!-- built files will be auto injected -->
|
<% } %>
|
||||||
</body>
|
<% } %>
|
||||||
</html>
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
|
||||||
|
Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
<% if (htmlWebpackPlugin.options.cdn) { %>
|
||||||
|
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
|
||||||
|
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -28,5 +28,5 @@ nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
</script>
|
</script>
|
||||||
@@ -7,7 +7,7 @@ import admin from './module/admin.js'
|
|||||||
* 如果你想调用8002端口,就用'/xiaozhi-esp32-api/api/v1',请与vue.config.js的devServer配置相结合,方便跨域请求
|
* 如果你想调用8002端口,就用'/xiaozhi-esp32-api/api/v1',请与vue.config.js的devServer配置相结合,方便跨域请求
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
|
// const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
|
||||||
// 8002开发完成完成后使用这个
|
// 8002开发完成完成后使用这个
|
||||||
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
|
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getServiceUrl() {
|
export function getServiceUrl() {
|
||||||
return DEV_API_SERVICE
|
return '/xiaozhi-esp32-api'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,12 @@ function httpHandlerError(info, callBack) {
|
|||||||
/** 请求成功,退出该函数 可以根据项目需求来判断是否请求成功。这里判断的是status为200的时候是成功 */
|
/** 请求成功,退出该函数 可以根据项目需求来判断是否请求成功。这里判断的是status为200的时候是成功 */
|
||||||
let networkError = false
|
let networkError = false
|
||||||
if (info.status === 200) {
|
if (info.status === 200) {
|
||||||
|
|
||||||
if (info.data.code === 'success' || info.data.code === 0 || info.data.code === undefined) {
|
if (info.data.code === 'success' || info.data.code === 0 || info.data.code === undefined) {
|
||||||
return networkError
|
return networkError
|
||||||
}else if (info.data.code === 401) {
|
}else if (info.data.code === 401) {
|
||||||
goToPage(Constant.PAGE.LOGIN, true)
|
console.log('触发 401,清除 Token 并跳转登录页');
|
||||||
|
store.commit('clearAuth');
|
||||||
|
window.location.href = '/login';
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
showDanger(info.data.msg)
|
showDanger(info.data.msg)
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ export default {
|
|||||||
.url(`${getServiceUrl()}/api/v1/user/change-password`)
|
.url(`${getServiceUrl()}/api/v1/user/change-password`)
|
||||||
.method('PUT')
|
.method('PUT')
|
||||||
.data({
|
.data({
|
||||||
old_password: oldPassword,
|
password: oldPassword,
|
||||||
new_password: newPassword,
|
newPassword: newPassword,
|
||||||
})
|
})
|
||||||
.success((res) => {
|
.success((res) => {
|
||||||
RequestService.clearRequestTime();
|
RequestService.clearRequestTime();
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -2,7 +2,7 @@
|
|||||||
<el-dialog :visible.sync="visible" width="400px" center>
|
<el-dialog :visible.sync="visible" 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 style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||||
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||||
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
|
<img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
|
||||||
</div>
|
</div>
|
||||||
添加智慧体
|
添加智慧体
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :visible.sync="visible" width="400px" center>
|
<form>
|
||||||
<div
|
<el-dialog :visible.sync="value" width="400px" center>
|
||||||
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
|
||||||
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||||
<img src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
|
<img loading="lazy" src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
|
||||||
</div>
|
</div>
|
||||||
修改密码
|
<div style="height: 1px;background: #e8f0ff;"/>
|
||||||
</div>
|
<div style="margin: 22px 15px;">
|
||||||
<div style="height: 1px;background: #e8f0ff;"/>
|
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||||
<div style="margin: 22px 15px;">
|
<div style="color: red;display: inline-block;">*</div>
|
||||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
旧密码:
|
||||||
<div style="color: red;display: inline-block;">*</div>
|
</div>
|
||||||
旧密码:
|
<div class="input-46" style="margin-top: 12px;">
|
||||||
|
<el-input placeholder="请输入旧密码" v-model="oldPassword" type="password" show-password/>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
||||||
|
<div style="color: red;display: inline-block;">*</div>
|
||||||
|
新密码:
|
||||||
|
</div>
|
||||||
|
<div class="input-46" style="margin-top: 12px;">
|
||||||
|
<el-input placeholder="请输入新密码" v-model="newPassword" type="password" show-password/>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
||||||
|
<div style="color: red;display: inline-block;">*</div>
|
||||||
|
确认新密码:
|
||||||
|
</div>
|
||||||
|
<div class="input-46" style="margin-top: 12px;">
|
||||||
|
<el-input placeholder="请再次输入新密码" v-model="confirmNewPassword" type="password" show-password/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-46" style="margin-top: 12px;">
|
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||||
<el-input placeholder="请输入旧密码" v-model="oldPassword" type="password" show-password/>
|
<div class="dialog-btn" @click="confirm">
|
||||||
|
确定
|
||||||
|
</div>
|
||||||
|
<div class="dialog-btn"
|
||||||
|
style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;"
|
||||||
|
@click="cancel">
|
||||||
|
取消
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
</el-dialog>
|
||||||
<div style="color: red;display: inline-block;">*</div>
|
</form>
|
||||||
新密码:
|
|
||||||
</div>
|
|
||||||
<div class="input-46" style="margin-top: 12px;">
|
|
||||||
<el-input placeholder="请输入新密码" v-model="newPassword" type="password" show-password/>
|
|
||||||
</div>
|
|
||||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
|
||||||
<div style="color: red;display: inline-block;">*</div>
|
|
||||||
确认新密码:
|
|
||||||
</div>
|
|
||||||
<div class="input-46" style="margin-top: 12px;">
|
|
||||||
<el-input placeholder="请再次输入新密码" v-model="confirmNewPassword" type="password" show-password/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
|
||||||
<div class="dialog-btn" @click="confirm">
|
|
||||||
确定
|
|
||||||
</div>
|
|
||||||
<div class="dialog-btn"
|
|
||||||
style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;"
|
|
||||||
@click="cancel">
|
|
||||||
取消
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import userApi from '@/apis/module/user';
|
import userApi from '@/apis/module/user';
|
||||||
|
import { mapActions } from 'vuex';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ChangePasswordDialog',
|
name: 'ChangePasswordDialog',
|
||||||
props: {
|
props: {
|
||||||
visible: {type: Boolean, required: true}
|
value: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -61,6 +63,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
...mapActions(['logout']), // 引入Vuex的logout action
|
||||||
confirm() {
|
confirm() {
|
||||||
if (!this.oldPassword.trim() || !this.newPassword.trim() || !this.confirmNewPassword.trim()) {
|
if (!this.oldPassword.trim() || !this.newPassword.trim() || !this.confirmNewPassword.trim()) {
|
||||||
this.$message.error('请填写所有字段');
|
this.$message.error('请填写所有字段');
|
||||||
@@ -75,22 +78,24 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接调用修改密码接口
|
// 修改后的接口调用
|
||||||
userApi.changePassword(this.oldPassword, this.newPassword, (res) => {
|
userApi.changePassword(this.oldPassword, this.newPassword, (res) => {
|
||||||
if (res.code === 0) {
|
if (res.data.code === 0) {
|
||||||
this.$message.error(res.msg || '密码修改失败');
|
this.$message.success('密码修改成功,请重新登录');
|
||||||
} else {
|
this.logout().then(() => {
|
||||||
this.$message.success('密码修改成功');
|
this.$router.push('/login');
|
||||||
this.$emit('confirm', res);
|
this.$emit('update:visible', false);
|
||||||
this.$emit('update:visible', false);
|
});
|
||||||
this.resetForm();
|
}else{
|
||||||
|
this.$message.error(res.data.msg || '密码修改失败');
|
||||||
}
|
}
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
this.$message.error(err.msg || '密码修改失败');
|
this.$message.error(err.msg || '密码修改失败');
|
||||||
});
|
});
|
||||||
|
this.$emit('input', false);
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
this.$emit('update:visible', false);
|
this.$emit('input', false);
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
},
|
},
|
||||||
resetForm() {
|
resetForm() {
|
||||||
|
|||||||
@@ -3,22 +3,24 @@
|
|||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<!-- 左侧元素 -->
|
<!-- 左侧元素 -->
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<img alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/>
|
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/>
|
||||||
<img alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
|
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 中间导航菜单 -->
|
<!-- 中间导航菜单 -->
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome">
|
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome">
|
||||||
<img alt="" src="@/assets/header/roboot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/>
|
|
||||||
|
<img loading="lazy" alt="" src="@/assets/header/robot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||||
|
|
||||||
智能体管理
|
智能体管理
|
||||||
</div>
|
</div>
|
||||||
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement">
|
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement">
|
||||||
<img alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/>
|
<img loading="lazy" alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||||
用户管理
|
用户管理
|
||||||
</div>
|
</div>
|
||||||
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig">
|
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig">
|
||||||
<img alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/>
|
<img loading="lazy" alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||||
模型配置
|
模型配置
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="serach"
|
v-model="search"
|
||||||
placeholder="输入名称搜索.."
|
placeholder="输入名称搜索.."
|
||||||
class="custom-search-input"
|
class="custom-search-input"
|
||||||
@keyup.enter.native="handleSearch"
|
@keyup.enter.native="handleSearch"
|
||||||
@@ -35,10 +37,10 @@
|
|||||||
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
|
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<img alt="" src="@/assets/home/avatar.png" class="avatar-img"/>
|
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img"/>
|
||||||
<el-dropdown trigger="click" class="user-dropdown">
|
<el-dropdown trigger="click" class="user-dropdown">
|
||||||
<span class="el-dropdown-link">
|
<span class="el-dropdown-link">
|
||||||
{{ userInfo.mobile || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
|
{{ userInfo.username || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
|
||||||
</span>
|
</span>
|
||||||
<el-dropdown-menu slot="dropdown">
|
<el-dropdown-menu slot="dropdown">
|
||||||
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
|
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 修改密码弹窗 -->
|
<!-- 修改密码弹窗 -->
|
||||||
<ChangePasswordDialog :visible.sync="isChangePasswordDialogVisible"/>
|
<ChangePasswordDialog v-model="isChangePasswordDialogVisible"/>
|
||||||
</el-header>
|
</el-header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ export default {
|
|||||||
props: ['devices'], // 接收父组件设备列表
|
props: ['devices'], // 接收父组件设备列表
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
serach: '',
|
search: '',
|
||||||
userInfo: {
|
userInfo: {
|
||||||
username: '',
|
username: '',
|
||||||
mobile: ''
|
mobile: ''
|
||||||
@@ -99,7 +101,7 @@ export default {
|
|||||||
|
|
||||||
// 处理搜索
|
// 处理搜索
|
||||||
handleSearch() {
|
handleSearch() {
|
||||||
const searchValue = this.serach.trim();
|
const searchValue = this.search.trim();
|
||||||
let filteredDevices;
|
let filteredDevices;
|
||||||
|
|
||||||
if (!searchValue) {
|
if (!searchValue) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import store from './store'
|
|||||||
import 'normalize.css/normalize.css' // A modern alternative to CSS resets
|
import 'normalize.css/normalize.css' // A modern alternative to CSS resets
|
||||||
import ElementUI from 'element-ui';
|
import ElementUI from 'element-ui';
|
||||||
import 'element-ui/lib/theme-chalk/index.css';
|
import 'element-ui/lib/theme-chalk/index.css';
|
||||||
|
import './styles/global.scss'
|
||||||
|
|
||||||
Vue.use(ElementUI);
|
Vue.use(ElementUI);
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default new Vuex.Store({
|
|||||||
logout({ commit }) {
|
logout({ commit }) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
commit('clearAuth')
|
commit('clearAuth')
|
||||||
resolve()
|
window.location.href = '/login';
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 覆盖 autofill 样式
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
textarea:-webkit-autofill:hover,
|
||||||
|
textarea:-webkit-autofill:focus,
|
||||||
|
select:-webkit-autofill,
|
||||||
|
select:-webkit-autofill:hover,
|
||||||
|
select:-webkit-autofill:focus {
|
||||||
|
-webkit-box-shadow: 0 0 0px 1000px transparent inset;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
语音设置
|
语音设置
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
|
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
|
||||||
<img alt="" src="@/assets/model/inner_conf.png">
|
<img loading="lazy" alt="" src="@/assets/model/inner_conf.png">
|
||||||
导入配置
|
导入配置
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
|
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
|
||||||
<img alt="" src="@/assets/model/output_conf.png">
|
<img loading="lazy" alt="" src="@/assets/model/output_conf.png">
|
||||||
导出配置
|
导出配置
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<el-header>
|
<el-header>
|
||||||
<div
|
<div
|
||||||
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||||
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
||||||
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
<el-main style="position: relative;">
|
<el-main style="position: relative;">
|
||||||
<div class="login-box">
|
<div class="login-box" @keyup.enter="login">
|
||||||
<div
|
<div
|
||||||
style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
|
style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
|
||||||
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
||||||
<div class="login-text">登录</div>
|
<div class="login-text">登录</div>
|
||||||
<div class="login-welcome">
|
<div class="login-welcome">
|
||||||
WELCOME TO LOGIN
|
WELCOME TO LOGIN
|
||||||
@@ -20,19 +20,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="padding: 0 30px;">
|
<div style="padding: 0 30px;">
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
|
||||||
<el-input v-model="form.username" placeholder="请输入用户名"/>
|
<el-input v-model="form.username" placeholder="请输入用户名"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||||
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
|
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
||||||
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
||||||
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
|
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
|
||||||
</div>
|
</div>
|
||||||
<img v-if="captchaUrl"
|
<img loading="lazy" v-if="captchaUrl"
|
||||||
:src="captchaUrl"
|
:src="captchaUrl"
|
||||||
alt="验证码"
|
alt="验证码"
|
||||||
style="width: 150px; height: 40px; cursor: pointer;"
|
style="width: 150px; height: 40px; cursor: pointer;"
|
||||||
@@ -87,8 +87,11 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
fetchCaptcha() {
|
fetchCaptcha() {
|
||||||
|
console.log(this.$store.getters.getToken)
|
||||||
if (this.$store.getters.getToken) {
|
if (this.$store.getters.getToken) {
|
||||||
goToPage('/home')
|
if (this.$route.path !== '/home'){
|
||||||
|
this.$router.push('/home')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.captchaUuid = getUUID();
|
this.captchaUuid = getUUID();
|
||||||
|
|
||||||
@@ -96,10 +99,8 @@ export default {
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const blob = new Blob([res.data], {type: res.data.type});
|
const blob = new Blob([res.data], {type: res.data.type});
|
||||||
this.captchaUrl = URL.createObjectURL(blob);
|
this.captchaUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
console.error('验证码加载异常:', error);
|
showDanger('验证码加载失败,点击刷新');
|
||||||
showDanger('验证码加载失败,点击刷新')
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,12 +131,13 @@ export default {
|
|||||||
|
|
||||||
this.form.captchaId = this.captchaUuid
|
this.form.captchaId = this.captchaUuid
|
||||||
Api.user.login(this.form, ({data}) => {
|
Api.user.login(this.form, ({data}) => {
|
||||||
console.log(data)
|
if (data.code === 0) {
|
||||||
showSuccess('登陆成功!')
|
showSuccess('登录成功!');
|
||||||
// 将令牌存储到 Vuex 中
|
this.$store.commit('setToken', JSON.stringify(data.data));
|
||||||
this.$store.commit('setToken', JSON.stringify(data.data))
|
goToPage('/home');
|
||||||
|
} else {
|
||||||
goToPage('/home')
|
showDanger(data.msg || '登录失败');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重新获取验证码
|
// 重新获取验证码
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<!-- 保持相同的头部 -->
|
<!-- 保持相同的头部 -->
|
||||||
<el-header>
|
<el-header>
|
||||||
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||||
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
||||||
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<!-- 修改标题部分 -->
|
<!-- 修改标题部分 -->
|
||||||
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
|
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
|
||||||
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
||||||
<div class="login-text">注册</div>
|
<div class="login-text">注册</div>
|
||||||
<div class="login-welcome">
|
<div class="login-welcome">
|
||||||
WELCOME TO REGISTER
|
WELCOME TO REGISTER
|
||||||
@@ -23,29 +23,29 @@
|
|||||||
<div style="padding: 0 30px;">
|
<div style="padding: 0 30px;">
|
||||||
<!-- 用户名输入框 -->
|
<!-- 用户名输入框 -->
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
|
||||||
<el-input v-model="form.username" placeholder="请输入用户名"/>
|
<el-input v-model="form.username" placeholder="请输入用户名"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 密码输入框 -->
|
<!-- 密码输入框 -->
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||||
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
|
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 新增确认密码 -->
|
<!-- 新增确认密码 -->
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||||
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
|
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 验证码部分保持相同 -->
|
<!-- 验证码部分保持相同 -->
|
||||||
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
||||||
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
||||||
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
||||||
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
|
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
|
||||||
</div>
|
</div>
|
||||||
<img v-if="captchaUrl"
|
<img loading="lazy" v-if="captchaUrl"
|
||||||
:src="captchaUrl"
|
:src="captchaUrl"
|
||||||
alt="验证码"
|
alt="验证码"
|
||||||
style="width: 150px; height: 40px; cursor: pointer;"
|
style="width: 150px; height: 40px; cursor: pointer;"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
|
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;">
|
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
|
||||||
<img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
|
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
|
||||||
</div>
|
</div>
|
||||||
{{ form.agentName }}
|
{{ form.agentName }}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
重制
|
重制
|
||||||
</div>
|
</div>
|
||||||
<div class="clear-text">
|
<div class="clear-text">
|
||||||
<img src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
|
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
|
||||||
保存配置后,需要重启设备,新的配置才会生效。
|
保存配置后,需要重启设备,新的配置才会生效。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,70 +18,68 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
|
||||||
import Recorder from 'opus-recorder';
|
import Recorder from 'opus-recorder';
|
||||||
import { OpusDecoder } from 'opus-decoder';
|
import { OpusDecoder } from 'opus-decoder';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TestPage',
|
name: 'TestPage',
|
||||||
setup() {
|
data() {
|
||||||
const messages = ref([]);
|
return {
|
||||||
const chatContainer = ref(null);
|
messages: [],
|
||||||
const wsStatus = ref('disconnected');
|
wsStatus: 'disconnected',
|
||||||
const isRecording = ref(false);
|
isRecording: false,
|
||||||
let ws = null;
|
ws: null,
|
||||||
let recorder = null;
|
recorder: null,
|
||||||
let stream = null;
|
stream: null,
|
||||||
let audioContext = null;
|
audioContext: null,
|
||||||
let sourceNode = null;
|
sourceNode: null,
|
||||||
let audioDecoder = null;
|
audioDecoder: null,
|
||||||
let audioBufferQueue = []; // 当前句子的音频缓冲区
|
audioBufferQueue: [], // 当前句子的音频缓冲区
|
||||||
let playbackQueue = []; // 播放队列,存储待播放的句子
|
playbackQueue: [], // 播放队列,存储待播放的句子
|
||||||
let isPlaying = false;
|
isPlaying: false,
|
||||||
|
}
|
||||||
const connectWebSocket = () => {
|
},
|
||||||
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
methods: {
|
||||||
ws.binaryType = 'arraybuffer';
|
connectWebSocket() {
|
||||||
ws.onopen = () => {
|
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
||||||
wsStatus.value = 'connected';
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.wsStatus = 'connected';
|
||||||
console.log('WebSocket 连接成功');
|
console.log('WebSocket 连接成功');
|
||||||
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
||||||
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
||||||
};
|
};
|
||||||
ws.onmessage = async (event) => {
|
this.ws.onmessage = async (event) => {
|
||||||
if (typeof event.data === 'string') {
|
if (typeof event.data === 'string') {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
console.log('收到文本消息:', msg);
|
console.log('收到文本消息:', msg);
|
||||||
|
|
||||||
if (msg.type === 'stt') {
|
if (msg.type === 'stt') {
|
||||||
messages.value.push({ role: 'user', content: msg.text });
|
this.messages.push({ role: 'user', content: msg.text });
|
||||||
scrollToBottom();
|
this.scrollToBottom();
|
||||||
} else if (msg.type === 'llm') {
|
} else if (msg.type === 'llm') {
|
||||||
messages.value.push({ role: 'assistant', content: msg.text });
|
this.messages.push({ role: 'assistant', content: msg.text });
|
||||||
scrollToBottom();
|
this.scrollToBottom();
|
||||||
} else if (msg.type === 'tts') {
|
} else if (msg.type === 'tts') {
|
||||||
if (msg.state === 'sentence_start') {
|
if (msg.state === 'sentence_start') {
|
||||||
// 开始新句子,清空当前缓冲区
|
this.audioBufferQueue = [];
|
||||||
audioBufferQueue = [];
|
const lastMessage = this.messages[this.messages.length - 1];
|
||||||
const lastMessage = messages.value[messages.value.length - 1];
|
|
||||||
if (!lastMessage || lastMessage.content !== msg.text) {
|
if (!lastMessage || lastMessage.content !== msg.text) {
|
||||||
messages.value.push({ role: 'assistant', content: msg.text });
|
this.messages.push({ role: 'assistant', content: msg.text });
|
||||||
scrollToBottom();
|
this.scrollToBottom();
|
||||||
}
|
}
|
||||||
} else if (msg.state === 'sentence_end') {
|
} else if (msg.state === 'sentence_end') {
|
||||||
// 句子结束,将当前缓冲区加入播放队列
|
if (this.audioBufferQueue.length > 0) {
|
||||||
if (audioBufferQueue.length > 0) {
|
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||||
playbackQueue.push([...audioBufferQueue]);
|
this.audioBufferQueue = [];
|
||||||
audioBufferQueue = [];
|
this.playNextInQueue();
|
||||||
playNextInQueue(); // 尝试播放队列中的下一句
|
|
||||||
}
|
}
|
||||||
} else if (msg.state === 'stop') {
|
} else if (msg.state === 'stop') {
|
||||||
console.log('TTS 任务结束');
|
console.log('TTS 任务结束');
|
||||||
// 确保所有剩余音频播放
|
if (this.audioBufferQueue.length > 0) {
|
||||||
if (audioBufferQueue.length > 0) {
|
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||||
playbackQueue.push([...audioBufferQueue]);
|
this.audioBufferQueue = [];
|
||||||
audioBufferQueue = [];
|
this.playNextInQueue();
|
||||||
playNextInQueue();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'hello') {
|
} else if (msg.type === 'hello') {
|
||||||
@@ -96,18 +94,17 @@ export default {
|
|||||||
console.log('音频帧前8字节:', frameHead);
|
console.log('音频帧前8字节:', frameHead);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = audioDecoder.decodeFrame(opusFrame);
|
const decoded = this.audioDecoder.decodeFrame(opusFrame);
|
||||||
console.log('解码结果:', decoded);
|
console.log('解码结果:', decoded);
|
||||||
if (decoded && decoded.channelData && decoded.channelData[0]) {
|
if (decoded && decoded.channelData && decoded.channelData[0]) {
|
||||||
const pcmData = decoded.channelData[0];
|
const pcmData = decoded.channelData[0];
|
||||||
if (pcmData.length > 0) {
|
if (pcmData.length > 0) {
|
||||||
audioBufferQueue.push(pcmData);
|
this.audioBufferQueue.push(pcmData);
|
||||||
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
|
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
|
||||||
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放
|
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
|
||||||
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) {
|
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||||
playbackQueue.push([...audioBufferQueue]);
|
this.audioBufferQueue = [];
|
||||||
audioBufferQueue = [];
|
this.playNextInQueue();
|
||||||
playNextInQueue();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('解码成功,但 PCM 数据长度为 0');
|
console.warn('解码成功,但 PCM 数据长度为 0');
|
||||||
@@ -120,39 +117,39 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onerror = (error) => {
|
this.ws.onerror = (error) => {
|
||||||
console.error('WebSocket 错误:', error);
|
console.error('WebSocket 错误:', error);
|
||||||
wsStatus.value = 'error';
|
this.wsStatus = 'error';
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
wsStatus.value = 'disconnected';
|
this.wsStatus = 'disconnected';
|
||||||
console.log('WebSocket 断开');
|
console.log('WebSocket 断开');
|
||||||
setTimeout(connectWebSocket, 1000);
|
setTimeout(this.connectWebSocket, 1000);
|
||||||
};
|
};
|
||||||
};
|
},
|
||||||
|
scrollToBottom() {
|
||||||
const scrollToBottom = () => {
|
this.$nextTick(() => {
|
||||||
nextTick(() => {
|
if (this.$refs.chatContainer) {
|
||||||
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
async playNextInQueue() {
|
||||||
const playNextInQueue = async () => {
|
if (this.isPlaying || this.playbackQueue.length === 0) {
|
||||||
if (isPlaying || playbackQueue.length === 0) {
|
return;
|
||||||
return; // 正在播放或队列为空,等待下次触发
|
|
||||||
}
|
}
|
||||||
isPlaying = true;
|
this.isPlaying = true;
|
||||||
|
|
||||||
if (!audioContext || audioContext.state === 'closed') {
|
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句
|
const pcmBuffers = this.playbackQueue.shift();
|
||||||
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
|
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
|
||||||
if (totalLength === 0) {
|
if (totalLength === 0) {
|
||||||
console.error('音频缓冲区总长度为 0,无法播放');
|
console.error('音频缓冲区总长度为 0,无法播放');
|
||||||
isPlaying = false;
|
this.isPlaying = false;
|
||||||
playNextInQueue(); // 尝试播放下一句
|
this.playNextInQueue();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,22 +160,21 @@ export default {
|
|||||||
offset += pcm.length;
|
offset += pcm.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000);
|
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
|
||||||
audioBuffer.getChannelData(0).set(mergedPcm);
|
audioBuffer.getChannelData(0).set(mergedPcm);
|
||||||
|
|
||||||
const source = audioContext.createBufferSource();
|
const source = this.audioContext.createBufferSource();
|
||||||
source.buffer = audioBuffer;
|
source.buffer = audioBuffer;
|
||||||
source.connect(audioContext.destination);
|
source.connect(this.audioContext.destination);
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
isPlaying = false;
|
this.isPlaying = false;
|
||||||
console.log('音频播放结束');
|
console.log('音频播放结束');
|
||||||
playNextInQueue(); // 播放结束后继续下一句
|
this.playNextInQueue();
|
||||||
};
|
};
|
||||||
source.start();
|
source.start();
|
||||||
console.log('开始播放音频,总长度:', totalLength);
|
console.log('开始播放音频,总长度:', totalLength);
|
||||||
};
|
},
|
||||||
|
stripOggContainer(data) {
|
||||||
const stripOggContainer = (data) => {
|
|
||||||
let arrayBuffer;
|
let arrayBuffer;
|
||||||
if (data instanceof ArrayBuffer) {
|
if (data instanceof ArrayBuffer) {
|
||||||
arrayBuffer = data;
|
arrayBuffer = data;
|
||||||
@@ -227,23 +223,22 @@ export default {
|
|||||||
}
|
}
|
||||||
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
|
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
|
||||||
return frames;
|
return frames;
|
||||||
};
|
},
|
||||||
|
async initRecorder() {
|
||||||
const initRecorder = async () => {
|
|
||||||
console.log('开始初始化录音');
|
console.log('开始初始化录音');
|
||||||
try {
|
try {
|
||||||
if (stream) {
|
if (this.stream) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
}
|
}
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
console.log('获取麦克风权限成功,stream:', stream);
|
console.log('获取麦克风权限成功,stream:', this.stream);
|
||||||
|
|
||||||
if (!audioContext || audioContext.state === 'closed') {
|
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||||
}
|
}
|
||||||
sourceNode = audioContext.createMediaStreamSource(stream);
|
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
|
||||||
|
|
||||||
recorder = new Recorder({
|
this.recorder = new Recorder({
|
||||||
encoderPath: '/encoderWorker.min.js',
|
encoderPath: '/encoderWorker.min.js',
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
numberOfChannels: 1,
|
numberOfChannels: 1,
|
||||||
@@ -252,17 +247,16 @@ export default {
|
|||||||
encoderFrameSize: 60,
|
encoderFrameSize: 60,
|
||||||
encoderBitRate: 24000,
|
encoderBitRate: 24000,
|
||||||
monitorGain: 0,
|
monitorGain: 0,
|
||||||
sourceNode: sourceNode,
|
sourceNode: this.sourceNode,
|
||||||
ogg: false,
|
ogg: false,
|
||||||
streamPages: false,
|
streamPages: false,
|
||||||
maxFramesPerPage: 1,
|
maxFramesPerPage: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
recorder.ondataavailable = (data) => {
|
this.recorder.ondataavailable = (data) => {
|
||||||
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
|
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
|
||||||
const frames = stripOggContainer(data);
|
const frames = this.stripOggContainer(data);
|
||||||
frames.forEach((frame, index) => {
|
frames.forEach((frame, index) => {
|
||||||
const frameView = new DataView(frame);
|
|
||||||
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
|
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -273,8 +267,8 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(frame);
|
this.ws.send(frame);
|
||||||
console.log('发送裸 Opus 帧:', frame.byteLength);
|
console.log('发送裸 Opus 帧:', frame.byteLength);
|
||||||
} else {
|
} else {
|
||||||
console.warn('WebSocket 未连接,跳过发送');
|
console.warn('WebSocket 未连接,跳过发送');
|
||||||
@@ -282,16 +276,16 @@ export default {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.onstart = () => {
|
this.recorder.onstart = () => {
|
||||||
console.log('录音已启动');
|
console.log('录音已启动');
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.onstop = () => {
|
this.recorder.onstop = () => {
|
||||||
console.log('录音停止');
|
console.log('录音停止');
|
||||||
stream.getTracks().forEach(track => track.stop());
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
stream = null;
|
this.stream = null;
|
||||||
sourceNode = null;
|
this.sourceNode = null;
|
||||||
scrollToBottom();
|
this.scrollToBottom();
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Recorder 初始化成功');
|
console.log('Recorder 初始化成功');
|
||||||
@@ -299,48 +293,43 @@ export default {
|
|||||||
console.error('初始化录音失败:', err);
|
console.error('初始化录音失败:', err);
|
||||||
alert('无法访问麦克风或录音初始化失败,请检查权限');
|
alert('无法访问麦克风或录音初始化失败,请检查权限');
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
async toggleRecording() {
|
||||||
const toggleRecording = async () => {
|
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
|
||||||
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value);
|
if (!this.recorder) {
|
||||||
if (!recorder) {
|
await this.initRecorder();
|
||||||
await initRecorder();
|
if (!this.recorder) {
|
||||||
if (!recorder) {
|
|
||||||
console.error('recorder 初始化失败');
|
console.error('recorder 初始化失败');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isRecording.value) {
|
if (this.isRecording) {
|
||||||
console.log('停止录音');
|
console.log('停止录音');
|
||||||
recorder.stop();
|
this.recorder.stop();
|
||||||
isRecording.value = false;
|
this.isRecording = false;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
console.log('开始录音');
|
console.log('开始录音');
|
||||||
await initRecorder();
|
await this.initRecorder();
|
||||||
await recorder.start();
|
await this.recorder.start();
|
||||||
console.log('录音开始后,状态:', recorder.state);
|
console.log('录音开始后,状态:', this.recorder.state);
|
||||||
isRecording.value = true;
|
this.isRecording = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('录音启动失败:', err);
|
console.error('录音启动失败:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
},
|
||||||
onMounted(() => {
|
mounted() {
|
||||||
console.log('组件挂载,初始化 WebSocket');
|
console.log('组件挂载,初始化 WebSocket');
|
||||||
connectWebSocket();
|
this.connectWebSocket();
|
||||||
});
|
},
|
||||||
|
destroyed() {
|
||||||
onUnmounted(() => {
|
if (this.ws) this.ws.close();
|
||||||
if (ws) ws.close();
|
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
|
||||||
if (stream) stream.getTracks().forEach(track => track.stop());
|
if (this.recorder) this.recorder.stop();
|
||||||
if (recorder) recorder.stop();
|
if (this.audioContext) this.audioContext.close();
|
||||||
if (audioContext) audioContext.close();
|
if (this.audioDecoder) this.audioDecoder.destroy();
|
||||||
if (audioDecoder) audioDecoder.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,24 +1,143 @@
|
|||||||
const { defineConfig } = require('@vue/cli-service');
|
const { defineConfig } = require('@vue/cli-service');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
|
// TerserPlugin 用于压缩 JavaScript
|
||||||
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
|
// CompressionPlugin 开启 Gzip 压缩
|
||||||
|
const CompressionPlugin = require('compression-webpack-plugin')
|
||||||
|
// BundleAnalyzerPlugin 用于分析打包后的文件
|
||||||
|
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||||
|
// 引入 path 模块
|
||||||
|
const path = require('path');
|
||||||
// 确保加载 .env 文件
|
// 确保加载 .env 文件
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
devServer: {
|
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
|
||||||
// Bug 修复:将代理配置为环境变量中定义的 API 基础 URL
|
devServer: {
|
||||||
port: 8001, // 指定端口为 8001
|
port: 8001, // 指定端口为 8001
|
||||||
proxy: {
|
proxy: {
|
||||||
'/xiaozhi-esp32-api': {
|
'/xiaozhi-esp32-api': {
|
||||||
target: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8002', // 后端 API 的基础 URL
|
target: process.env.VUE_APP_API_BASE_URL, // 后端 API 的基础 URL
|
||||||
changeOrigin: true, // 允许跨域
|
changeOrigin: true, // 允许跨域
|
||||||
// pathRewrite: {
|
pathRewrite: {
|
||||||
// '^/api': '', // 路径重写
|
'^/xiaozhi-esp32-api': '/xiaozhi-esp32-api',
|
||||||
// },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
client: {
|
|
||||||
overlay: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
overlay: false, // 不显示 webpack 错误覆盖层
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chainWebpack: config => {
|
||||||
|
|
||||||
|
// 修改 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'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 代码分割优化
|
||||||
|
config.optimization.splitChunks({
|
||||||
|
chunks: 'all',
|
||||||
|
minSize: 20000,
|
||||||
|
maxSize: 250000,
|
||||||
|
cacheGroups: {
|
||||||
|
vendors: {
|
||||||
|
name: 'chunk-vendors',
|
||||||
|
test: /[\\/]node_modules[\\/]/,
|
||||||
|
priority: -10,
|
||||||
|
chunks: 'initial',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
name: 'chunk-common',
|
||||||
|
minChunks: 2,
|
||||||
|
priority: -20,
|
||||||
|
chunks: 'initial',
|
||||||
|
reuseExistingChunk: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启用优化设置
|
||||||
|
config.optimization.usedExports(true);
|
||||||
|
config.optimization.concatenateModules(true);
|
||||||
|
config.optimization.minimize(true);
|
||||||
|
},
|
||||||
|
configureWebpack: config => {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
// 开启多线程编译
|
||||||
|
config.optimization = {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserPlugin({
|
||||||
|
parallel: true,
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true,
|
||||||
|
pure_funcs: ['console.log']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
};
|
||||||
|
config.plugins.push(
|
||||||
|
new CompressionPlugin({
|
||||||
|
algorithm: 'gzip',
|
||||||
|
test: /\.(js|css|html|svg)$/,
|
||||||
|
threshold: 20480,
|
||||||
|
minRatio: 0.8
|
||||||
|
})
|
||||||
|
);
|
||||||
|
config.externals = {
|
||||||
|
'vue': 'Vue',
|
||||||
|
'vue-router': 'VueRouter',
|
||||||
|
'vuex': 'Vuex',
|
||||||
|
'element-ui': 'ELEMENT',
|
||||||
|
'axios': 'axios',
|
||||||
|
'opus-decoder': 'OpusDecoder'
|
||||||
|
};
|
||||||
|
if (process.env.ANALYZE === 'true') { // 通过环境变量控制
|
||||||
|
config.plugins.push(
|
||||||
|
new BundleAnalyzerPlugin({
|
||||||
|
analyzerMode: 'server', // 开启本地服务器模式
|
||||||
|
openAnalyzer: true, // 自动打开浏览器
|
||||||
|
analyzerPort: 8888 // 指定端口号
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
config.cache = {
|
||||||
|
type: 'filesystem', // 使用文件系统缓存
|
||||||
|
cacheDirectory: path.resolve(__dirname, '.webpack_cache'), // 自定义缓存目录
|
||||||
|
allowCollectingMemory: true, // 启用内存收集
|
||||||
|
compression: 'gzip', // 启用gzip压缩缓存
|
||||||
|
maxAge: 5184000000, // 缓存有效期为 1个月
|
||||||
|
buildDependencies: {
|
||||||
|
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' 目录
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ prompt: |
|
|||||||
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
|
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
|
||||||
- 问专业知识 → 先用梗回答,被追问才展示真实理解
|
- 问专业知识 → 先用梗回答,被追问才展示真实理解
|
||||||
绝不:
|
绝不:
|
||||||
- 使用大陆网络流行语
|
- 长篇大论,叽叽歪歪
|
||||||
- 长时间严肃对话
|
- 长时间严肃对话
|
||||||
|
|
||||||
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
|
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
|
||||||
@@ -182,11 +182,21 @@ ASR:
|
|||||||
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
|
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
|
||||||
output_dir: tmp/
|
output_dir: tmp/
|
||||||
DoubaoASR:
|
DoubaoASR:
|
||||||
|
# 可以在这里申请相关Key等信息
|
||||||
|
# https://console.volcengine.com/speech/app
|
||||||
type: doubao
|
type: doubao
|
||||||
appid: 你的火山引擎语音合成服务appid
|
appid: 你的火山引擎语音合成服务appid
|
||||||
access_token: 你的火山引擎语音合成服务access_token
|
access_token: 你的火山引擎语音合成服务access_token
|
||||||
cluster: volcengine_input_common
|
cluster: volcengine_input_common
|
||||||
output_dir: tmp/
|
output_dir: tmp/
|
||||||
|
TencentASR:
|
||||||
|
# token申请地址:https://console.cloud.tencent.com/cam/capi
|
||||||
|
# 免费领取资源:https://console.cloud.tencent.com/asr/resourcebundle
|
||||||
|
type: tencent
|
||||||
|
appid: 你的腾讯语音合成服务appid
|
||||||
|
secret_id: 你的腾讯语音合成服务secret_id
|
||||||
|
secret_key: 你的腾讯语音合成服务secret_key
|
||||||
|
output_dir: tmp/
|
||||||
VAD:
|
VAD:
|
||||||
SileroVAD:
|
SileroVAD:
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
@@ -274,6 +284,8 @@ LLM:
|
|||||||
CozeLLM:
|
CozeLLM:
|
||||||
# 定义LLM API类型
|
# 定义LLM API类型
|
||||||
type: coze
|
type: coze
|
||||||
|
# 你可以在这里找到个人令牌
|
||||||
|
# https://www.coze.cn/open/oauth/pats
|
||||||
# bot_id和user_id的内容写在引号之内
|
# bot_id和user_id的内容写在引号之内
|
||||||
bot_id: "你的bot_id"
|
bot_id: "你的bot_id"
|
||||||
user_id: "你的user_id"
|
user_id: "你的user_id"
|
||||||
@@ -289,6 +301,8 @@ LLM:
|
|||||||
type: fastgpt
|
type: fastgpt
|
||||||
# 如果使用fastgpt,配置文件里prompt(提示词)是无效的,需要在fastgpt控制台设置提示词
|
# 如果使用fastgpt,配置文件里prompt(提示词)是无效的,需要在fastgpt控制台设置提示词
|
||||||
base_url: https://host/api/v1
|
base_url: https://host/api/v1
|
||||||
|
# 你可以在这里找到你的api_key
|
||||||
|
# https://cloud.tryfastgpt.ai/account/apikey
|
||||||
api_key: fastgpt-xxx
|
api_key: fastgpt-xxx
|
||||||
variables:
|
variables:
|
||||||
k: "v"
|
k: "v"
|
||||||
@@ -530,6 +544,8 @@ TTS:
|
|||||||
OpenAITTS:
|
OpenAITTS:
|
||||||
# openai官方文本转语音服务,可支持全球大多数语种
|
# openai官方文本转语音服务,可支持全球大多数语种
|
||||||
type: openai
|
type: openai
|
||||||
|
# 你可以在这里获取到 api key
|
||||||
|
# https://platform.openai.com/api-keys
|
||||||
api_key: 你的openai api key
|
api_key: 你的openai api key
|
||||||
# 国内需要使用代理
|
# 国内需要使用代理
|
||||||
api_url: https://api.openai.com/v1/audio/speech
|
api_url: https://api.openai.com/v1/audio/speech
|
||||||
|
|||||||
@@ -3,24 +3,32 @@ import sys
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from config.settings import load_config
|
from config.settings import load_config
|
||||||
|
|
||||||
SERVER_VERSION = "0.1.16"
|
SERVER_VERSION = "0.1.17"
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
"""从配置文件中读取日志配置,并设置日志输出格式和级别"""
|
"""从配置文件中读取日志配置,并设置日志输出格式和级别"""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
log_config = config["log"]
|
log_config = config["log"]
|
||||||
log_format = log_config.get("log_format", "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>")
|
log_format = log_config.get(
|
||||||
log_format_file = log_config.get("log_format_file", "{time:YYYY-MM-DD HH:mm:ss} - {version_{selected_module}} - {name} - {level} - {extra[tag]} - {message}")
|
"log_format",
|
||||||
|
"<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>",
|
||||||
|
)
|
||||||
|
log_format_file = log_config.get(
|
||||||
|
"log_format_file",
|
||||||
|
"{time:YYYY-MM-DD HH:mm:ss} - {version_{selected_module}} - {name} - {level} - {extra[tag]} - {message}",
|
||||||
|
)
|
||||||
|
|
||||||
selected_module = config.get("selected_module")
|
selected_module = config.get("selected_module")
|
||||||
selected_module_str = ''.join([value[0] + value[1] for key, value in selected_module.items()])
|
selected_module_str = "".join(
|
||||||
|
[value[0] + value[1] for key, value in selected_module.items()]
|
||||||
|
)
|
||||||
|
|
||||||
log_format = log_format.replace("{version}", SERVER_VERSION)
|
log_format = log_format.replace("{version}", SERVER_VERSION)
|
||||||
log_format = log_format.replace("{selected_module}", selected_module_str)
|
log_format = log_format.replace("{selected_module}", selected_module_str)
|
||||||
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
|
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
|
||||||
log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
|
log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
|
||||||
|
|
||||||
|
|
||||||
log_level = log_config.get("log_level", "INFO")
|
log_level = log_config.get("log_level", "INFO")
|
||||||
log_dir = log_config.get("log_dir", "tmp")
|
log_dir = log_config.get("log_dir", "tmp")
|
||||||
log_file = log_config.get("log_file", "server.log")
|
log_file = log_config.get("log_file", "server.log")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async def check_direct_exit(conn, text):
|
|||||||
|
|
||||||
async def analyze_intent_with_llm(conn, text):
|
async def analyze_intent_with_llm(conn, text):
|
||||||
"""使用LLM分析用户意图"""
|
"""使用LLM分析用户意图"""
|
||||||
if not hasattr(conn, 'intent') or not conn.intent:
|
if not hasattr(conn, "intent") or not conn.intent:
|
||||||
logger.bind(tag=TAG).warning("意图识别服务未初始化")
|
logger.bind(tag=TAG).warning("意图识别服务未初始化")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -69,7 +69,9 @@ async def process_intent_result(conn, intent_result, original_text):
|
|||||||
# 检查是否有function_call
|
# 检查是否有function_call
|
||||||
if "function_call" in intent_data:
|
if "function_call" in intent_data:
|
||||||
# 直接从意图识别获取了function_call
|
# 直接从意图识别获取了function_call
|
||||||
logger.bind(tag=TAG).info(f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}")
|
logger.bind(tag=TAG).info(
|
||||||
|
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
|
||||||
|
)
|
||||||
function_name = intent_data["function_call"]["name"]
|
function_name = intent_data["function_call"]["name"]
|
||||||
if function_name == "continue_chat":
|
if function_name == "continue_chat":
|
||||||
return False
|
return False
|
||||||
@@ -83,7 +85,7 @@ async def process_intent_result(conn, intent_result, original_text):
|
|||||||
function_call_data = {
|
function_call_data = {
|
||||||
"name": function_name,
|
"name": function_name,
|
||||||
"id": str(uuid.uuid4().hex),
|
"id": str(uuid.uuid4().hex),
|
||||||
"arguments": function_args
|
"arguments": function_args,
|
||||||
}
|
}
|
||||||
|
|
||||||
await send_stt_message(conn, original_text)
|
await send_stt_message(conn, original_text)
|
||||||
@@ -91,16 +93,24 @@ async def process_intent_result(conn, intent_result, original_text):
|
|||||||
# 使用executor执行函数调用和结果处理
|
# 使用executor执行函数调用和结果处理
|
||||||
def process_function_call():
|
def process_function_call():
|
||||||
conn.dialogue.put(Message(role="user", content=original_text))
|
conn.dialogue.put(Message(role="user", content=original_text))
|
||||||
result = conn.func_handler.handle_llm_function_call(conn, function_call_data)
|
result = conn.func_handler.handle_llm_function_call(
|
||||||
if result and function_name != 'play_music':
|
conn, function_call_data
|
||||||
|
)
|
||||||
|
if result and function_name != "play_music":
|
||||||
# 获取当前最新的文本索引
|
# 获取当前最新的文本索引
|
||||||
text = result.response
|
text = result.response
|
||||||
if text is None:
|
if text is None:
|
||||||
text = result.result
|
text = result.result
|
||||||
if text is not None:
|
if text is not None:
|
||||||
text_index = conn.tts_last_text_index + 1 if hasattr(conn, 'tts_last_text_index') else 0
|
text_index = (
|
||||||
|
conn.tts_last_text_index + 1
|
||||||
|
if hasattr(conn, "tts_last_text_index")
|
||||||
|
else 0
|
||||||
|
)
|
||||||
conn.recode_first_last_text(text, text_index)
|
conn.recode_first_last_text(text, text_index)
|
||||||
future = conn.executor.submit(conn.speak_and_play, text, text_index)
|
future = conn.executor.submit(
|
||||||
|
conn.speak_and_play, text, text_index
|
||||||
|
)
|
||||||
conn.llm_finish_task = True
|
conn.llm_finish_task = True
|
||||||
conn.tts_queue.put(future)
|
conn.tts_queue.put(future)
|
||||||
conn.dialogue.put(Message(role="assistant", content=text))
|
conn.dialogue.put(Message(role="assistant", content=text))
|
||||||
@@ -121,10 +131,14 @@ def extract_text_in_brackets(s):
|
|||||||
:param s: 输入字符串
|
:param s: 输入字符串
|
||||||
:return: 中括号内的文字,如果不存在则返回空字符串
|
:return: 中括号内的文字,如果不存在则返回空字符串
|
||||||
"""
|
"""
|
||||||
left_bracket_index = s.find('[')
|
left_bracket_index = s.find("[")
|
||||||
right_bracket_index = s.find(']')
|
right_bracket_index = s.find("]")
|
||||||
|
|
||||||
if left_bracket_index != -1 and right_bracket_index != -1 and left_bracket_index < right_bracket_index:
|
if (
|
||||||
return s[left_bracket_index + 1:right_bracket_index]
|
left_bracket_index != -1
|
||||||
|
and right_bracket_index != -1
|
||||||
|
and left_bracket_index < right_bracket_index
|
||||||
|
):
|
||||||
|
return s[left_bracket_index + 1 : right_bracket_index]
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
import wave
|
||||||
|
import opuslib_next
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from core.providers.asr.base import ASRProviderBase
|
||||||
|
from config.logger import setup_logging
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
logger = setup_logging()
|
||||||
|
|
||||||
|
class ASRProvider(ASRProviderBase):
|
||||||
|
API_URL = "https://asr.tencentcloudapi.com"
|
||||||
|
API_VERSION = "2019-06-14"
|
||||||
|
FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3
|
||||||
|
|
||||||
|
def __init__(self, config: dict, delete_audio_file: bool = True):
|
||||||
|
self.secret_id = config.get("secret_id")
|
||||||
|
self.secret_key = config.get("secret_key")
|
||||||
|
self.output_dir = config.get("output_dir")
|
||||||
|
|
||||||
|
# 确保输出目录存在
|
||||||
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
|
||||||
|
"""将Opus音频数据解码并保存为WAV文件"""
|
||||||
|
|
||||||
|
file_name = f"tencent_asr_{session_id}_{uuid.uuid4()}.wav"
|
||||||
|
file_path = os.path.join(self.output_dir, file_name)
|
||||||
|
|
||||||
|
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
||||||
|
pcm_data = []
|
||||||
|
|
||||||
|
for opus_packet in opus_data:
|
||||||
|
try:
|
||||||
|
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
||||||
|
pcm_data.append(pcm_frame)
|
||||||
|
except opuslib_next.OpusError as e:
|
||||||
|
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
||||||
|
|
||||||
|
with wave.open(file_path, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2) # 2 bytes = 16-bit
|
||||||
|
wf.setframerate(16000)
|
||||||
|
wf.writeframes(b"".join(pcm_data))
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_opus(opus_data: List[bytes]) -> bytes:
|
||||||
|
"""将Opus音频数据解码为PCM数据"""
|
||||||
|
import opuslib_next
|
||||||
|
|
||||||
|
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
||||||
|
pcm_data = []
|
||||||
|
|
||||||
|
for opus_packet in opus_data:
|
||||||
|
try:
|
||||||
|
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
||||||
|
pcm_data.append(pcm_frame)
|
||||||
|
except opuslib_next.OpusError as e:
|
||||||
|
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return b"".join(pcm_data)
|
||||||
|
|
||||||
|
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""将语音数据转换为文本"""
|
||||||
|
if not opus_data:
|
||||||
|
logger.bind(tag=TAG).warn("音频数据为空!")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 检查配置是否已设置
|
||||||
|
if not self.secret_id or not self.secret_key:
|
||||||
|
logger.bind(tag=TAG).error("腾讯云语音识别配置未设置,无法进行识别")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 将Opus音频数据解码为PCM
|
||||||
|
pcm_data = self.decode_opus(opus_data)
|
||||||
|
|
||||||
|
# 将音频数据转换为Base64编码
|
||||||
|
base64_audio = base64.b64encode(pcm_data).decode('utf-8')
|
||||||
|
|
||||||
|
# 构建请求体
|
||||||
|
request_body = self._build_request_body(base64_audio)
|
||||||
|
|
||||||
|
# 获取认证头
|
||||||
|
timestamp, authorization = self._get_auth_headers(request_body)
|
||||||
|
|
||||||
|
# 发送请求
|
||||||
|
start_time = time.time()
|
||||||
|
result = self._send_request(request_body, timestamp, authorization)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.bind(tag=TAG).debug(f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}")
|
||||||
|
|
||||||
|
return result, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"处理音频时发生错误!{e}", exc_info=True)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _build_request_body(self, base64_audio: str) -> str:
|
||||||
|
"""构建请求体"""
|
||||||
|
request_map = {
|
||||||
|
"ProjectId": 0,
|
||||||
|
"SubServiceType": 2, # 一句话识别
|
||||||
|
"EngSerViceType": "16k_zh", # 中文普通话通用
|
||||||
|
"SourceType": 1, # 音频数据来源为语音文件
|
||||||
|
"VoiceFormat": self.FORMAT, # 音频格式
|
||||||
|
"Data": base64_audio, # Base64编码的音频数据
|
||||||
|
"DataLen": len(base64_audio) # 数据长度
|
||||||
|
}
|
||||||
|
return json.dumps(request_map)
|
||||||
|
|
||||||
|
def _get_auth_headers(self, request_body: str) -> Tuple[str, str]:
|
||||||
|
"""获取认证头"""
|
||||||
|
try:
|
||||||
|
# 获取当前UTC时间戳
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
timestamp = str(int(now.timestamp()))
|
||||||
|
date = now.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 服务名称必须是 "asr"
|
||||||
|
service = "asr"
|
||||||
|
|
||||||
|
# 拼接凭证范围
|
||||||
|
credential_scope = f"{date}/{service}/tc3_request"
|
||||||
|
|
||||||
|
# 使用TC3-HMAC-SHA256签名方法
|
||||||
|
algorithm = "TC3-HMAC-SHA256"
|
||||||
|
|
||||||
|
# 构建规范请求字符串
|
||||||
|
http_request_method = "POST"
|
||||||
|
canonical_uri = "/"
|
||||||
|
canonical_query_string = ""
|
||||||
|
|
||||||
|
# 注意:头部信息需要按照ASCII升序排列,且key和value都转为小写
|
||||||
|
# 必须包含content-type和host头部
|
||||||
|
content_type = "application/json; charset=utf-8"
|
||||||
|
host = "asr.tencentcloudapi.com"
|
||||||
|
action = "SentenceRecognition" # 接口名称
|
||||||
|
|
||||||
|
# 构建规范头部信息,注意顺序和格式
|
||||||
|
canonical_headers = f"content-type:{content_type.lower()}\n" + \
|
||||||
|
f"host:{host.lower()}\n" + \
|
||||||
|
f"x-tc-action:{action.lower()}\n"
|
||||||
|
|
||||||
|
signed_headers = "content-type;host;x-tc-action"
|
||||||
|
|
||||||
|
# 请求体哈希值
|
||||||
|
payload_hash = self._sha256_hex(request_body)
|
||||||
|
|
||||||
|
# 构建规范请求字符串
|
||||||
|
canonical_request = f"{http_request_method}\n" + \
|
||||||
|
f"{canonical_uri}\n" + \
|
||||||
|
f"{canonical_query_string}\n" + \
|
||||||
|
f"{canonical_headers}\n" + \
|
||||||
|
f"{signed_headers}\n" + \
|
||||||
|
f"{payload_hash}"
|
||||||
|
|
||||||
|
# 计算规范请求的哈希值
|
||||||
|
hashed_canonical_request = self._sha256_hex(canonical_request)
|
||||||
|
|
||||||
|
# 构建待签名字符串
|
||||||
|
string_to_sign = f"{algorithm}\n" + \
|
||||||
|
f"{timestamp}\n" + \
|
||||||
|
f"{credential_scope}\n" + \
|
||||||
|
f"{hashed_canonical_request}"
|
||||||
|
|
||||||
|
# 计算签名密钥
|
||||||
|
secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date)
|
||||||
|
secret_service = self._hmac_sha256(secret_date, service)
|
||||||
|
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||||||
|
|
||||||
|
# 计算签名
|
||||||
|
signature = self._bytes_to_hex(self._hmac_sha256(secret_signing, string_to_sign))
|
||||||
|
|
||||||
|
# 构建授权头
|
||||||
|
authorization = f"{algorithm} " + \
|
||||||
|
f"Credential={self.secret_id}/{credential_scope}, " + \
|
||||||
|
f"SignedHeaders={signed_headers}, " + \
|
||||||
|
f"Signature={signature}"
|
||||||
|
|
||||||
|
return timestamp, authorization
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True)
|
||||||
|
raise RuntimeError(f"生成认证头失败: {e}")
|
||||||
|
|
||||||
|
def _send_request(self, request_body: str, timestamp: str, authorization: str) -> Optional[str]:
|
||||||
|
"""发送请求到腾讯云API"""
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Host": "asr.tencentcloudapi.com",
|
||||||
|
"Authorization": authorization,
|
||||||
|
"X-TC-Action": "SentenceRecognition",
|
||||||
|
"X-TC-Version": self.API_VERSION,
|
||||||
|
"X-TC-Timestamp": timestamp,
|
||||||
|
"X-TC-Region": "ap-shanghai"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(self.API_URL, headers=headers, data=request_body)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
raise IOError(f"请求失败: {response.status_code} {response.reason}")
|
||||||
|
|
||||||
|
response_json = response.json()
|
||||||
|
|
||||||
|
# 检查是否有错误
|
||||||
|
if "Response" in response_json and "Error" in response_json["Response"]:
|
||||||
|
error = response_json["Response"]["Error"]
|
||||||
|
error_code = error["Code"]
|
||||||
|
error_message = error["Message"]
|
||||||
|
raise IOError(f"API返回错误: {error_code}: {error_message}")
|
||||||
|
|
||||||
|
# 提取识别结果
|
||||||
|
if "Response" in response_json and "Result" in response_json["Response"]:
|
||||||
|
return response_json["Response"]["Result"]
|
||||||
|
else:
|
||||||
|
logger.bind(tag=TAG).warn(f"响应中没有识别结果: {response_json}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"发送请求失败: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sha256_hex(self, data: str) -> str:
|
||||||
|
"""计算字符串的SHA256哈希值"""
|
||||||
|
digest = hashlib.sha256(data.encode('utf-8')).digest()
|
||||||
|
return self._bytes_to_hex(digest)
|
||||||
|
|
||||||
|
def _hmac_sha256(self, key, data: str) -> bytes:
|
||||||
|
"""计算HMAC-SHA256"""
|
||||||
|
if isinstance(key, str):
|
||||||
|
key = key.encode('utf-8')
|
||||||
|
|
||||||
|
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
def _bytes_to_hex(self, bytes_data: bytes) -> str:
|
||||||
|
"""字节数组转十六进制字符串"""
|
||||||
|
return ''.join(f"{b:02x}" for b in bytes_data)
|
||||||
@@ -10,7 +10,7 @@ HASS_CACHE = {}
|
|||||||
def append_devices_to_prompt(conn):
|
def append_devices_to_prompt(conn):
|
||||||
if conn.use_function_call_mode:
|
if conn.use_function_call_mode:
|
||||||
funcs = conn.config["Intent"]["function_call"].get("functions", [])
|
funcs = conn.config["Intent"]["function_call"].get("functions", [])
|
||||||
if "hass_get_state" in funcs or "hass_get_state" in funcs:
|
if "hass_get_state" in funcs or "hass_set_state" in funcs:
|
||||||
prompt = "下面是我家智能设备,可以通过homeassistant控制\n"
|
prompt = "下面是我家智能设备,可以通过homeassistant控制\n"
|
||||||
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
|
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
|
||||||
if len(devices) == 0:
|
if len(devices) == 0:
|
||||||
@@ -27,9 +27,13 @@ def initialize_hass_handler(conn):
|
|||||||
if HASS_CACHE == {}:
|
if HASS_CACHE == {}:
|
||||||
if conn.use_function_call_mode:
|
if conn.use_function_call_mode:
|
||||||
funcs = conn.config["Intent"]["function_call"].get("functions", [])
|
funcs = conn.config["Intent"]["function_call"].get("functions", [])
|
||||||
if "hass_get_state" in funcs or "hass_get_state" in funcs:
|
if "hass_get_state" in funcs or "hass_set_state" in funcs:
|
||||||
HASS_CACHE['base_url'] = conn.config["plugins"]["home_assistant"].get("base_url")
|
HASS_CACHE["base_url"] = conn.config["plugins"]["home_assistant"].get(
|
||||||
HASS_CACHE['api_key'] = conn.config["plugins"]["home_assistant"].get("api_key")
|
"base_url"
|
||||||
|
)
|
||||||
|
HASS_CACHE["api_key"] = conn.config["plugins"]["home_assistant"].get(
|
||||||
|
"api_key"
|
||||||
|
)
|
||||||
|
|
||||||
check_model_key("home_assistant", HASS_CACHE['api_key'])
|
check_model_key("home_assistant", HASS_CACHE["api_key"])
|
||||||
return HASS_CACHE
|
return HASS_CACHE
|
||||||
|
|||||||
@@ -23,5 +23,4 @@ bs4==0.0.2
|
|||||||
modelscope==1.23.2
|
modelscope==1.23.2
|
||||||
sherpa_onnx==1.11.0
|
sherpa_onnx==1.11.0
|
||||||
mcp==1.4.1
|
mcp==1.4.1
|
||||||
|
|
||||||
cnlunar==0.2.0
|
cnlunar==0.2.0
|
||||||
|
|||||||