Compare commits

..
43 Commits
Author SHA1 Message Date
欣南科技andGitHub c12d3df073 Merge pull request #634 from xinnan-tech/fix-hot
Fix hot
2025-04-02 09:28:25 +08:00
hrz 82b1548ae0 update:优化异常登录 2025-04-02 09:23:36 +08:00
hrzandGitHub afd1d2ae6a Merge pull request #632 from joey-zhou/config
config文件添加缺失的api key申请地址
2025-04-02 09:19:10 +08:00
joeyandGitHub fd59aff4d0 Merge branch 'main' into config 2025-04-01 23:58:54 +08:00
JoeyZhou 59133b9d8d config文件添加缺失的api key申请地址 2025-04-01 23:56:10 +08:00
欣南科技andGitHub cca42309c4 Merge pull request #630 from xinnan-tech/test-manager-api
合并多个修改
2025-04-01 23:21:20 +08:00
hrz 510ee1b282 update:manager-api已接近完成,现在进入正式接口联调阶段 2025-04-01 23:14:15 +08:00
hrz c4d3020a1d update:增加tencentasr领取信息 2025-04-01 23:10:10 +08:00
hrzandGitHub f3af30ae53 Merge pull request #607 from joey-zhou/tencent_asr
feat:新增腾讯云ASR语音识别服务
2025-04-01 22:09:59 +08:00
JoeyZhou 6d3f270a8f 添加tencent asr配置文件 2025-04-01 22:07:22 +08:00
hrz 86621661df update:日志配置在logback配置文件即可 2025-04-01 22:04:04 +08:00
hrz d668917694 fix:用户分页页码参数错误bug 2025-04-01 21:49:26 +08:00
hrz 8bc1d09c4a fix:hass_set_state字符判断错误 2025-04-01 21:31:50 +08:00
hrzandGitHub 76b90c179b Merge pull request #614 from joey-zhou/manager_build
优化后端Java部署文档方案
2025-04-01 21:26:47 +08:00
CGDandGitHub 7c389a0641 Merge pull request #624 from xinnan-tech/web_api_user_test
user接口修改并测试
2025-04-01 11:37:24 +08:00
CGDandGitHub 4241120853 Merge branch 'main' into web_api_user_test 2025-04-01 11:35:55 +08:00
Ran_Chen 5998fc3edd user接口修改并测试 2025-04-01 10:59:35 +08:00
joeyandGitHub 392f8a1ce0 Merge branch 'main' into manager_build 2025-04-01 09:50:40 +08:00
欣南科技andGitHub 04203c4103 update:更新版本号 (#623) 2025-04-01 09:37:22 +08:00
hrz 188cd7f023 update:更新版本号 2025-04-01 09:36:16 +08:00
欣南科技andGitHub 8d17f5bd3c merge:web端底层优化
update:web端底层优化
2025-04-01 09:22:27 +08:00
hrz 9d39880e9d update:让VUE_APP_TITLE变量控制title 2025-04-01 09:20:50 +08:00
hrzandGitHub f27e213d13 Merge pull request #620 from huangjunsen0406/main
feat: 同步上游 main 分支并优化构建配置
2025-04-01 09:04:37 +08:00
huangjunsen0406 69ecb2c503 Merge remote-tracking branch 'upstream/main' 2025-04-01 00:45:43 +08:00
huangjunsen0406 0585a0732f perf(图片): 压缩图片大小、img标签启用原生lazy 2025-04-01 00:42:36 +08:00
huangjunsen fab4adb07e perf(构建): 优化构建时间:首次构建提升 50%,二次构建提升 30%
优化构建配置,提升构建效率和文件体积优化

1. 禁用生产环境的 source map:为了减少生产环境构建的文件体积,提高构建速度,生产环境下不再生成 source map。

2. 配置开发服务器代理:在 `devServer` 中配置了代理,解决了跨域问题,确保本地开发时可以顺利访问后端 API。

3. 动态插入 CDN 链接:在生产环境下,通过 `chainWebpack` 动态修改 HTML 插件配置,插入 CDN 链接,避免打包常用库(如 `vue`, `axios`,
`element-ui` 等),从而减少打包体积。

4. 代码分割优化:优化了 `splitChunks` 配置,减少了小模块的分割,提升了构建效率和缓存利用率。增加了 `minSize` 和 `maxSize` 配置,避免过多的小模块被拆分。

5. 启用多线程压缩:在生产环境中,配置了 `TerserPlugin` 以启用并行压缩,减少了 JS 文件的压缩时间。

6. 启用 Gzip 压缩:通过 `CompressionPlugin` 配置了 Gzip 压缩,只压缩大于 20KB 的文件,减少了构建后的文件体积。

7. 配置 `externals`:通过 `externals` 排除了 `vue`, `vue-router`, `vuex`, `element-ui`, `axios`,
`opus-decoder` 等库的打包,避免重复打包并通过 CDN 加载这些库。

8. 配置文件系统缓存:启用了 Webpack 文件系统缓存,加速了增量构建,缓存目录设置为 `.webpack_cache`,缓存有效期为一个月。

9. 配置 Webpack 别名:为项目中的路径配置了别名,简化了模块导入,减少了代码中的路径冗余。

10. 提供可选的打包分析:通过 `BundleAnalyzerPlugin` 插件,在 `ANALYZE=true` 环境变量下启用打包分析功能,帮助分析并优化构建后的文件。
2025-04-01 00:38:32 +08:00
joeyandGitHub 5c3ae278be Update README.md 2025-04-01 00:05:32 +08:00
joeyandGitHub 19a067f4e8 Update README.md 2025-04-01 00:04:16 +08:00
joeyandGitHub a7b5e0df6c Delete main/manager-api/start.sh 2025-04-01 00:02:40 +08:00
joeyandGitHub 83e9725d02 Merge branch 'main' into manager_build 2025-04-01 00:00:48 +08:00
JoeyZhou 086dea8594 fix:修复日志无法正常生成bug,采用logback控制日志 2025-03-31 23:58:12 +08:00
JoeyZhou d3c8e4badb feat:后端服务器新增启动脚本,自动建立log日志 2025-03-31 22:21:46 +08:00
joeyandGitHub 665318164e Update requirements.txt 2025-03-31 21:23:40 +08:00
joeyandGitHub 44f8254b43 Delete main/xiaozhi-server/core/providers/asr/vosk_local.py 2025-03-31 21:23:15 +08:00
joeyandGitHub e085fb1b10 Update config.yaml 2025-03-31 21:22:13 +08:00
joeyandGitHub 48ed5d5c9b Update .gitignore 2025-03-31 21:18:03 +08:00
huangjunsen 20c1f0576b build(环境变量): 📦 去除.env忽略、修改请求地址、控制台名称通过env获取
修改了环境变量的读取方式

调整了请求地址配置

控制台名称通过 .env 获取
2025-03-31 19:52:01 +08:00
huangjunsen fef8cece90 chore: 🔨 去除.env.development忽略 2025-03-31 19:40:57 +08:00
huangjunsen f549f26541 feat(style): 新增全局css覆写element ui 自动填充色 2025-03-31 17:35:46 +08:00
JoeyZhou f20c17f27d 优化部署文档:改进打包和服务启动命令
1. 修改打包命令为 mvn clean install,确保构建前清理旧文件
2. 改进服务启动命令,添加日志输出和进程ID保存
3. 规范化操作流程,提高部署可靠性
2025-03-31 16:13:32 +08:00
joeyandGitHub fd188f6f65 Merge branch 'main' into vosk_asr 2025-03-31 15:50:32 +08:00
JoeyZhou ee3808dd2f feat:新增vosk本地语音识别服务 2025-03-31 15:41:45 +08:00
JoeyZhou d2d97149fc feat:新增腾讯云ASR语音识别服务 2025-03-31 15:19:29 +08:00
40 changed files with 1902 additions and 1743 deletions
+4 -2
View File
@@ -75,6 +75,7 @@ docs/_build/
# PyBuilder
.pybuilder/
target/
*.pid
# Jupyter Notebook
.ipynb_checkpoints
@@ -106,7 +107,6 @@ celerybeat.pid
*.sage.py
# Environments
.env
.venv
env/
venv/
@@ -150,10 +150,12 @@ main/manager-web/node_modules
.config.yaml
.secrets.yaml
.private_config.yaml
.env.development
# model files
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
main/xiaozhi-server/models/sherpa-onnx*
my_wakeup_words.mp3
main/manager-api/.vscode
# Ignore webpack cache directory
main/manager-web/.webpack_cache/
+3 -2
View File
@@ -81,15 +81,16 @@ src/main/java/xiaozhi/AdminApplication.java
执行以下命令生产jar包
```
mvn install
mvn clean install
```
把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
@@ -82,8 +82,6 @@ public class Oauth2Filter extends AuthenticatingFilter {
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
try {
// 处理登录失败的异常
logger.error("onLoginFailure:登录失败!", e);
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result<Void> r = new Result<Void>().error(ErrorCode.UNAUTHORIZED, throwable.getMessage());
@@ -50,7 +50,7 @@ public class AdminController {
AdminPageUserDTO dto = new AdminPageUserDTO();
dto.setMobile((String) params.get("mobile"));
dto.setLimit((String) params.get(Constant.LIMIT));
dto.setPage((String) params.get("pages"));
dto.setPage((String) params.get(Constant.PAGE));
ValidatorUtils.validateEntity(dto);
PageData<AdminPageUserVO> page = sysUserService.page(dto);
@@ -1,35 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/base.xml" />
<logger name="org.springframework.web" level="INFO"/>
<logger name="org.springboot.sample" level="TRACE" />
<!-- 开发、测试环境 -->
<!-- 启用JansiConsoleAppender以确保控制台输出有颜色 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
<!-- 确保日志目录存在 -->
<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">
<logger name="org.springframework.web" level="INFO"/>
<logger name="org.springboot.sample" level="INFO" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
<!-- 开发环境也记录日志文件 -->
<appender-ref ref="FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
<logger name="xiaozhi" level="DEBUG" />
<logger name="org.springframework.web" level="INFO" />
<logger name="org.springboot.sample" level="INFO" />
</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">
<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">
<!--日志文件输出的文件名-->
<FileNamePattern>/home/system/logs/admin.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
<!--日志大小-->
<maxFileSize>1MB</maxFileSize>
<!--日志文件保留天数-->
<maxHistory>7</maxHistory>
<fileNamePattern>${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</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>
</configuration>
<!-- 确保日志目录存在 -->
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
<resetJUL>true</resetJUL>
</contextListener>
</configuration>
+1
View File
@@ -0,0 +1 @@
VUE_APP_TITLE=小智-智控台
+1
View File
@@ -0,0 +1 @@
VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun
+1
View File
@@ -0,0 +1 @@
VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/
-2
View File
@@ -6,8 +6,6 @@
开发使用代码编辑器,导入项目时,选择`manager-web`文件夹作为项目目录
参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
```
npm install
```
+13
View File
@@ -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'
]
}
+1010 -1424
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -4,9 +4,12 @@
"private": true,
"scripts": {
"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": {
"core-js": "^3.41.0",
"cross-env": "^7.0.3",
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
@@ -19,16 +22,25 @@
"xiaozhi": "file:"
},
"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-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"compression-webpack-plugin": "^11.1.0",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14"
"vue-template-compiler": "^2.6.14",
"webpack-bundle-analyzer": "^4.10.2"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
],
"sideEffects": [
"*.css",
"*.scss",
"*.vue"
]
}
+31 -15
View File
@@ -1,17 +1,33 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>小智-智控台</title>
</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 -->
</body>
</html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>
<%= process.env.VUE_APP_TITLE %>
</title>
<% if (htmlWebpackPlugin.options.cdn) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
<% } %>
<% } %>
</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>
+1 -1
View File
@@ -28,5 +28,5 @@ nav {
}
</style>
<script setup lang="ts">
<script>
</script>
+2 -2
View File
@@ -7,7 +7,7 @@ import admin from './module/admin.js'
* 如果你想调用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开发完成完成后使用这个
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
@@ -16,7 +16,7 @@ const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
* @returns {string}
*/
export function getServiceUrl() {
return DEV_API_SERVICE
return '/xiaozhi-esp32-api'
}
+3 -2
View File
@@ -105,11 +105,12 @@ function httpHandlerError(info, callBack) {
/** 请求成功,退出该函数 可以根据项目需求来判断是否请求成功。这里判断的是status为200的时候是成功 */
let networkError = false
if (info.status === 200) {
if (info.data.code === 'success' || info.data.code === 0 || info.data.code === undefined) {
return networkError
}else if (info.data.code === 401) {
goToPage(Constant.PAGE.LOGIN, true)
console.log('触发 401,清除 Token 并跳转登录页');
store.commit('clearAuth');
window.location.href = '/login';
return true
} else {
showDanger(info.data.msg)
+2 -2
View File
@@ -138,8 +138,8 @@ export default {
.url(`${getServiceUrl()}/api/v1/user/change-password`)
.method('PUT')
.data({
old_password: oldPassword,
new_password: newPassword,
password: oldPassword,
newPassword: newPassword,
})
.success((res) => {
RequestService.clearRequestTime();

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

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>
<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;">
<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>
@@ -1,57 +1,59 @@
<template>
<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;">
<form>
<el-dialog :visible.sync="value" width="400px" center>
<div
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 style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div>
旧密码
<div style="height: 1px;background: #e8f0ff;"/>
<div style="margin: 22px 15px;">
<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 class="input-46" style="margin-top: 12px;">
<el-input placeholder="请输入旧密码" v-model="oldPassword" type="password" show-password/>
<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>
<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 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>
</el-dialog>
</form>
</template>
<script>
import userApi from '@/apis/module/user';
import { mapActions } from 'vuex';
export default {
name: 'ChangePasswordDialog',
props: {
visible: {type: Boolean, required: true}
value: {
type: Boolean,
required: true
}
},
data() {
return {
@@ -61,6 +63,7 @@ export default {
}
},
methods: {
...mapActions(['logout']), // 引入Vuex的logout action
confirm() {
if (!this.oldPassword.trim() || !this.newPassword.trim() || !this.confirmNewPassword.trim()) {
this.$message.error('请填写所有字段');
@@ -75,22 +78,24 @@ export default {
return;
}
// 直接调用修改密码接口
// 修改后的接口调用
userApi.changePassword(this.oldPassword, this.newPassword, (res) => {
if (res.code === 0) {
this.$message.error(res.msg || '密码修改失败');
} else {
this.$message.success('密码修改成功');
this.$emit('confirm', res);
this.$emit('update:visible', false);
this.resetForm();
if (res.data.code === 0) {
this.$message.success('密码修改成功,请重新登录');
this.logout().then(() => {
this.$router.push('/login');
this.$emit('update:visible', false);
});
}else{
this.$message.error(res.data.msg || '密码修改失败');
}
}, (err) => {
this.$message.error(err.msg || '密码修改失败');
});
this.$emit('input', false);
},
cancel() {
this.$emit('update:visible', false);
this.$emit('input', false);
this.resetForm();
},
resetForm() {
+13 -11
View File
@@ -3,22 +3,24 @@
<div class="header-container">
<!-- 左侧元素 -->
<div class="header-left">
<img 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-logo.png" class="logo-img"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
</div>
<!-- 中间导航菜单 -->
<div class="header-center">
<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 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 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>
@@ -27,7 +29,7 @@
<div class="header-right">
<div class="search-container">
<el-input
v-model="serach"
v-model="search"
placeholder="输入名称搜索.."
class="custom-search-input"
@keyup.enter.native="handleSearch"
@@ -35,10 +37,10 @@
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
</el-input>
</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">
<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>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
@@ -50,7 +52,7 @@
</div>
<!-- 修改密码弹窗 -->
<ChangePasswordDialog :visible.sync="isChangePasswordDialogVisible"/>
<ChangePasswordDialog v-model="isChangePasswordDialogVisible"/>
</el-header>
</template>
@@ -68,7 +70,7 @@ export default {
props: ['devices'], // 接收父组件设备列表
data() {
return {
serach: '',
search: '',
userInfo: {
username: '',
mobile: ''
@@ -99,7 +101,7 @@ export default {
// 处理搜索
handleSearch() {
const searchValue = this.serach.trim();
const searchValue = this.search.trim();
let filteredDevices;
if (!searchValue) {
+1
View File
@@ -5,6 +5,7 @@ import store from './store'
import 'normalize.css/normalize.css' // A modern alternative to CSS resets
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import './styles/global.scss'
Vue.use(ElementUI);
Vue.config.productionTip = false
+1 -1
View File
@@ -39,7 +39,7 @@ export default new Vuex.Store({
logout({ commit }) {
return new Promise((resolve) => {
commit('clearAuth')
resolve()
window.location.href = '/login';
})
}
},
+14
View File
@@ -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;
}
+2 -2
View File
@@ -9,11 +9,11 @@
语音设置
</el-button>
<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 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>
</div>
+20 -18
View File
@@ -4,15 +4,15 @@
<el-header>
<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 alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div>
</el-header>
<el-main style="position: relative;">
<div class="login-box">
<div class="login-box" @keyup.enter="login">
<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-welcome">
WELCOME TO LOGIN
@@ -20,19 +20,19 @@
</div>
<div style="padding: 0 30px;">
<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="请输入用户名"/>
</div>
<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"/>
</div>
<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;">
<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;"/>
</div>
<img v-if="captchaUrl"
<img loading="lazy" v-if="captchaUrl"
:src="captchaUrl"
alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;"
@@ -87,8 +87,11 @@ export default {
},
methods: {
fetchCaptcha() {
console.log(this.$store.getters.getToken)
if (this.$store.getters.getToken) {
goToPage('/home')
if (this.$route.path !== '/home'){
this.$router.push('/home')
}
} else {
this.captchaUuid = getUUID();
@@ -96,10 +99,8 @@ export default {
if (res.status === 200) {
const blob = new Blob([res.data], {type: res.data.type});
this.captchaUrl = URL.createObjectURL(blob);
} else {
console.error('验证码加载异常:', error);
showDanger('验证码加载失败,点击刷新')
showDanger('验证码加载失败,点击刷新');
}
});
}
@@ -130,12 +131,13 @@ export default {
this.form.captchaId = this.captchaUuid
Api.user.login(this.form, ({data}) => {
console.log(data)
showSuccess('登成功!')
// 将令牌存储到 Vuex 中
this.$store.commit('setToken', JSON.stringify(data.data))
goToPage('/home')
if (data.code === 0) {
showSuccess('登成功!');
this.$store.commit('setToken', JSON.stringify(data.data));
goToPage('/home');
} else {
showDanger(data.msg || '登录失败');
}
})
// 重新获取验证码
+8 -8
View File
@@ -4,8 +4,8 @@
<!-- 保持相同的头部 -->
<el-header>
<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 alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div>
</el-header>
@@ -13,7 +13,7 @@
<div class="login-box">
<!-- 修改标题部分 -->
<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-welcome">
WELCOME TO REGISTER
@@ -23,29 +23,29 @@
<div style="padding: 0 30px;">
<!-- 用户名输入框 -->
<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="请输入用户名"/>
</div>
<!-- 密码输入框 -->
<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"/>
</div>
<!-- 新增确认密码 -->
<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"/>
</div>
<!-- 验证码部分保持相同 -->
<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;">
<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;"/>
</div>
<img v-if="captchaUrl"
<img loading="lazy" v-if="captchaUrl"
:src="captchaUrl"
alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;"
+2 -2
View File
@@ -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;">
<div
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>
{{ form.agentName }}
</div>
@@ -83,7 +83,7 @@
重制
</div>
<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>
+121 -132
View File
@@ -18,70 +18,68 @@
</template>
<script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import Recorder from 'opus-recorder';
import { OpusDecoder } from 'opus-decoder';
export default {
name: 'TestPage',
setup() {
const messages = ref([]);
const chatContainer = ref(null);
const wsStatus = ref('disconnected');
const isRecording = ref(false);
let ws = null;
let recorder = null;
let stream = null;
let audioContext = null;
let sourceNode = null;
let audioDecoder = null;
let audioBufferQueue = []; // 当前句子的音频缓冲区
let playbackQueue = []; // 播放队列,存储待播放的句子
let isPlaying = false;
const connectWebSocket = () => {
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
wsStatus.value = 'connected';
data() {
return {
messages: [],
wsStatus: 'disconnected',
isRecording: false,
ws: null,
recorder: null,
stream: null,
audioContext: null,
sourceNode: null,
audioDecoder: null,
audioBufferQueue: [], // 当前句子的音频缓冲区
playbackQueue: [], // 播放队列,存储待播放的句子
isPlaying: false,
}
},
methods: {
connectWebSocket() {
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.wsStatus = 'connected';
console.log('WebSocket 连接成功');
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
};
ws.onmessage = async (event) => {
this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg);
if (msg.type === 'stt') {
messages.value.push({ role: 'user', content: msg.text });
scrollToBottom();
this.messages.push({ role: 'user', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'llm') {
messages.value.push({ role: 'assistant', content: msg.text });
scrollToBottom();
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') {
// 开始新句子,清空当前缓冲区
audioBufferQueue = [];
const lastMessage = messages.value[messages.value.length - 1];
this.audioBufferQueue = [];
const lastMessage = this.messages[this.messages.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) {
messages.value.push({ role: 'assistant', content: msg.text });
scrollToBottom();
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
}
} else if (msg.state === 'sentence_end') {
// 句子结束,将当前缓冲区加入播放队列
if (audioBufferQueue.length > 0) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue(); // 尝试播放队列中的下一句
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else if (msg.state === 'stop') {
console.log('TTS 任务结束');
// 确保所有剩余音频播放
if (audioBufferQueue.length > 0) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue();
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
}
} else if (msg.type === 'hello') {
@@ -96,18 +94,17 @@ export default {
console.log('音频帧前8字节:', frameHead);
try {
const decoded = audioDecoder.decodeFrame(opusFrame);
const decoded = this.audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0];
if (pcmData.length > 0) {
audioBufferQueue.push(pcmData);
this.audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue();
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else {
console.warn('解码成功,但 PCM 数据长度为 0');
@@ -120,39 +117,39 @@ export default {
}
}
};
ws.onerror = (error) => {
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
wsStatus.value = 'error';
this.wsStatus = 'error';
};
ws.onclose = () => {
wsStatus.value = 'disconnected';
this.ws.onclose = () => {
this.wsStatus = 'disconnected';
console.log('WebSocket 断开');
setTimeout(connectWebSocket, 1000);
setTimeout(this.connectWebSocket, 1000);
};
};
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
},
scrollToBottom() {
this.$nextTick(() => {
if (this.$refs.chatContainer) {
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
}
});
};
const playNextInQueue = async () => {
if (isPlaying || playbackQueue.length === 0) {
return; // 正在播放或队列为空,等待下次触发
},
async playNextInQueue() {
if (this.isPlaying || this.playbackQueue.length === 0) {
return;
}
isPlaying = true;
this.isPlaying = true;
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
if (!this.audioContext || this.audioContext.state === 'closed') {
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);
if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放');
isPlaying = false;
playNextInQueue(); // 尝试播放下一句
this.isPlaying = false;
this.playNextInQueue();
return;
}
@@ -163,22 +160,21 @@ export default {
offset += pcm.length;
}
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000);
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm);
const source = audioContext.createBufferSource();
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.connect(this.audioContext.destination);
source.onended = () => {
isPlaying = false;
this.isPlaying = false;
console.log('音频播放结束');
playNextInQueue(); // 播放结束后继续下一句
this.playNextInQueue();
};
source.start();
console.log('开始播放音频,总长度:', totalLength);
};
const stripOggContainer = (data) => {
},
stripOggContainer(data) {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
@@ -227,23 +223,22 @@ export default {
}
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames;
};
const initRecorder = async () => {
},
async initRecorder() {
console.log('开始初始化录音');
try {
if (stream) {
stream.getTracks().forEach(track => track.stop());
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', stream);
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', this.stream);
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
if (!this.audioContext || this.audioContext.state === 'closed') {
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',
sampleRate: 16000,
numberOfChannels: 1,
@@ -252,17 +247,16 @@ export default {
encoderFrameSize: 60,
encoderBitRate: 24000,
monitorGain: 0,
sourceNode: sourceNode,
sourceNode: this.sourceNode,
ogg: false,
streamPages: false,
maxFramesPerPage: 1,
});
recorder.ondataavailable = (data) => {
this.recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = stripOggContainer(data);
const frames = this.stripOggContainer(data);
frames.forEach((frame, index) => {
const frameView = new DataView(frame);
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
@@ -273,8 +267,8 @@ export default {
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(frame);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength);
} else {
console.warn('WebSocket 未连接,跳过发送');
@@ -282,16 +276,16 @@ export default {
});
};
recorder.onstart = () => {
this.recorder.onstart = () => {
console.log('录音已启动');
};
recorder.onstop = () => {
this.recorder.onstop = () => {
console.log('录音停止');
stream.getTracks().forEach(track => track.stop());
stream = null;
sourceNode = null;
scrollToBottom();
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
this.sourceNode = null;
this.scrollToBottom();
};
console.log('Recorder 初始化成功');
@@ -299,48 +293,43 @@ export default {
console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限');
}
};
const toggleRecording = async () => {
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value);
if (!recorder) {
await initRecorder();
if (!recorder) {
},
async toggleRecording() {
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
if (!this.recorder) {
await this.initRecorder();
if (!this.recorder) {
console.error('recorder 初始化失败');
return;
}
}
if (isRecording.value) {
if (this.isRecording) {
console.log('停止录音');
recorder.stop();
isRecording.value = false;
this.recorder.stop();
this.isRecording = false;
} else {
try {
console.log('开始录音');
await initRecorder();
await recorder.start();
console.log('录音开始后,状态:', recorder.state);
isRecording.value = true;
await this.initRecorder();
await this.recorder.start();
console.log('录音开始后,状态:', this.recorder.state);
this.isRecording = true;
} catch (err) {
console.error('录音启动失败:', err);
}
}
};
onMounted(() => {
console.log('组件挂载,初始化 WebSocket');
connectWebSocket();
});
onUnmounted(() => {
if (ws) ws.close();
if (stream) stream.getTracks().forEach(track => track.stop());
if (recorder) recorder.stop();
if (audioContext) audioContext.close();
if (audioDecoder) audioDecoder.destroy();
});
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
},
},
mounted() {
console.log('组件挂载,初始化 WebSocket');
this.connectWebSocket();
},
destroyed() {
if (this.ws) this.ws.close();
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
if (this.recorder) this.recorder.stop();
if (this.audioContext) this.audioContext.close();
if (this.audioDecoder) this.audioDecoder.destroy();
}
};
</script>
+135 -16
View File
@@ -1,24 +1,143 @@
const { defineConfig } = require('@vue/cli-service');
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 文件
dotenv.config();
module.exports = defineConfig({
devServer: {
// Bug 修复:将代理配置为环境变量中定义的 API 基础 URL
port: 8001, // 指定端口为 8001
proxy: {
'/xiaozhi-esp32-api': {
target: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8002', // 后端 API 的基础 URL
changeOrigin: true, // 允许跨域
// pathRewrite: {
// '^/api': '', // 路径重写
// },
},
},
client: {
overlay: false,
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
devServer: {
port: 8001, // 指定端口为 8001
proxy: {
'/xiaozhi-esp32-api': {
target: process.env.VUE_APP_API_BASE_URL, // 后端 API 的基础 URL
changeOrigin: true, // 允许跨域
pathRewrite: {
'^/xiaozhi-esp32-api': '/xiaozhi-esp32-api',
},
},
},
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' 目录
}
}
},
});
+17 -1
View File
@@ -57,7 +57,7 @@ prompt: |
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
- 问专业知识 → 先用梗回答,被追问才展示真实理解
绝不:
- 使用大陆网络流行语
- 长篇大论,叽叽歪歪
- 长时间严肃对话
# 使用完声音文件后删除文件(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
output_dir: tmp/
DoubaoASR:
# 可以在这里申请相关Key等信息
# https://console.volcengine.com/speech/app
type: doubao
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
cluster: volcengine_input_common
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:
SileroVAD:
threshold: 0.5
@@ -274,6 +284,8 @@ LLM:
CozeLLM:
# 定义LLM API类型
type: coze
# 你可以在这里找到个人令牌
# https://www.coze.cn/open/oauth/pats
# bot_id和user_id的内容写在引号之内
bot_id: "你的bot_id"
user_id: "你的user_id"
@@ -289,6 +301,8 @@ LLM:
type: fastgpt
# 如果使用fastgpt,配置文件里prompt(提示词)是无效的,需要在fastgpt控制台设置提示词
base_url: https://host/api/v1
# 你可以在这里找到你的api_key
# https://cloud.tryfastgpt.ai/account/apikey
api_key: fastgpt-xxx
variables:
k: "v"
@@ -530,6 +544,8 @@ TTS:
OpenAITTS:
# openai官方文本转语音服务,可支持全球大多数语种
type: openai
# 你可以在这里获取到 api key
# https://platform.openai.com/api-keys
api_key: 你的openai api key
# 国内需要使用代理
api_url: https://api.openai.com/v1/audio/speech
+13 -5
View File
@@ -3,24 +3,32 @@ import sys
from loguru import logger
from config.settings import load_config
SERVER_VERSION = "0.1.16"
SERVER_VERSION = "0.1.17"
def setup_logging():
"""从配置文件中读取日志配置,并设置日志输出格式和级别"""
config = load_config()
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_file = log_config.get("log_format_file", "{time:YYYY-MM-DD HH:mm:ss} - {version_{selected_module}} - {name} - {level} - {extra[tag]} - {message}")
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_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_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("{selected_module}", selected_module_str)
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
log_level = log_config.get("log_level", "INFO")
log_dir = log_config.get("log_dir", "tmp")
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):
"""使用LLM分析用户意图"""
if not hasattr(conn, 'intent') or not conn.intent:
if not hasattr(conn, "intent") or not conn.intent:
logger.bind(tag=TAG).warning("意图识别服务未初始化")
return None
@@ -69,7 +69,9 @@ async def process_intent_result(conn, intent_result, original_text):
# 检查是否有function_call
if "function_call" in intent_data:
# 直接从意图识别获取了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"]
if function_name == "continue_chat":
return False
@@ -83,7 +85,7 @@ async def process_intent_result(conn, intent_result, original_text):
function_call_data = {
"name": function_name,
"id": str(uuid.uuid4().hex),
"arguments": function_args
"arguments": function_args,
}
await send_stt_message(conn, original_text)
@@ -91,16 +93,24 @@ async def process_intent_result(conn, intent_result, original_text):
# 使用executor执行函数调用和结果处理
def process_function_call():
conn.dialogue.put(Message(role="user", content=original_text))
result = conn.func_handler.handle_llm_function_call(conn, function_call_data)
if result and function_name != 'play_music':
result = conn.func_handler.handle_llm_function_call(
conn, function_call_data
)
if result and function_name != "play_music":
# 获取当前最新的文本索引
text = result.response
if text is None:
text = result.result
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)
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.tts_queue.put(future)
conn.dialogue.put(Message(role="assistant", content=text))
@@ -121,10 +131,14 @@ def extract_text_in_brackets(s):
:param s: 输入字符串
:return: 中括号内的文字,如果不存在则返回空字符串
"""
left_bracket_index = s.find('[')
right_bracket_index = s.find(']')
left_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:
return s[left_bracket_index + 1:right_bracket_index]
if (
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:
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):
if conn.use_function_call_mode:
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"
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
if len(devices) == 0:
@@ -27,9 +27,13 @@ def initialize_hass_handler(conn):
if HASS_CACHE == {}:
if conn.use_function_call_mode:
funcs = conn.config["Intent"]["function_call"].get("functions", [])
if "hass_get_state" in funcs or "hass_get_state" in funcs:
HASS_CACHE['base_url'] = conn.config["plugins"]["home_assistant"].get("base_url")
HASS_CACHE['api_key'] = conn.config["plugins"]["home_assistant"].get("api_key")
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["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
-1
View File
@@ -23,5 +23,4 @@ bs4==0.0.2
modelscope==1.23.2
sherpa_onnx==1.11.0
mcp==1.4.1
cnlunar==0.2.0