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/
|
||||
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/
|
||||
@@ -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>
|
||||
|
||||
@@ -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前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
|
||||
|
||||
```
|
||||
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,
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -28,5 +28,5 @@ nav {
|
||||
}
|
||||
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
<script>
|
||||
</script>
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
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>
|
||||
<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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,7 +39,7 @@ export default new Vuex.Store({
|
||||
logout({ commit }) {
|
||||
return new Promise((resolve) => {
|
||||
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 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>
|
||||
|
||||
@@ -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 || '登录失败');
|
||||
}
|
||||
})
|
||||
|
||||
// 重新获取验证码
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' 目录
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||