Merge pull request #620 from huangjunsen0406/main

feat: 同步上游 main 分支并优化构建配置
This commit is contained in:
hrz
2025-04-01 09:04:37 +08:00
committed by GitHub
25 changed files with 1356 additions and 1607 deletions
+3 -2
View File
@@ -106,7 +106,6 @@ celerybeat.pid
*.sage.py *.sage.py
# Environments # Environments
.env
.venv .venv
env/ env/
venv/ venv/
@@ -150,10 +149,12 @@ main/manager-web/node_modules
.config.yaml .config.yaml
.secrets.yaml .secrets.yaml
.private_config.yaml .private_config.yaml
.env.development
# model files # model files
main/xiaozhi-server/models/SenseVoiceSmall/model.pt main/xiaozhi-server/models/SenseVoiceSmall/model.pt
main/xiaozhi-server/models/sherpa-onnx* main/xiaozhi-server/models/sherpa-onnx*
my_wakeup_words.mp3 my_wakeup_words.mp3
main/manager-api/.vscode main/manager-api/.vscode
# Ignore webpack cache directory
main/manager-web/.webpack_cache/
+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/
+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, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build" "build": "vue-cli-service build",
"analyze": "cross-env ANALYZE=true vue-cli-service build"
}, },
"dependencies": { "dependencies": {
"core-js": "^3.41.0",
"cross-env": "^7.0.3",
"element-ui": "^2.15.14", "element-ui": "^2.15.14",
"flyio": "^0.6.14", "flyio": "^0.6.14",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
@@ -19,16 +22,25 @@
"xiaozhi": "file:" "xiaozhi": "file:"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.26.10",
"@vue/cli-plugin-router": "~5.0.0", "@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0", "@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0", "@vue/cli-service": "~5.0.0",
"compression-webpack-plugin": "^11.1.0",
"sass": "^1.32.7", "sass": "^1.32.7",
"sass-loader": "^12.0.0", "sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14" "vue-template-compiler": "^2.6.14",
"webpack-bundle-analyzer": "^4.10.2"
}, },
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
"last 2 versions", "last 2 versions",
"not dead" "not dead"
],
"sideEffects": [
"*.css",
"*.scss",
"*.vue"
] ]
} }
+11 -1
View File
@@ -5,7 +5,12 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>小智-智控台</title> <title><%= htmlWebpackPlugin.options.title %></title>
<% if (htmlWebpackPlugin.options.cdn) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
<% } %>
<% } %>
</head> </head>
<body> <body>
<noscript> <noscript>
@@ -13,5 +18,10 @@
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- 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> </body>
</html> </html>
+1 -1
View File
@@ -28,5 +28,5 @@ nav {
} }
</style> </style>
<script setup lang="ts"> <script>
</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配置相结合,方便跨域请求 * 如果你想调用8002端口,就用'/xiaozhi-esp32-api/api/v1',请与vue.config.js的devServer配置相结合,方便跨域请求
* *
*/ */
const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api' // const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
// 8002开发完成完成后使用这个 // 8002开发完成完成后使用这个
// const DEV_API_SERVICE = '/xiaozhi-esp32-api' // const DEV_API_SERVICE = '/xiaozhi-esp32-api'
@@ -16,7 +16,7 @@ const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
* @returns {string} * @returns {string}
*/ */
export function getServiceUrl() { export function getServiceUrl() {
return DEV_API_SERVICE return '/xiaozhi-esp32-api'
} }
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> <el-dialog :visible.sync="visible" width="400px" center>
<div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> <div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;"> <div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" /> <img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
</div> </div>
添加智慧体 添加智慧体
</div> </div>
@@ -4,7 +4,7 @@
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;"> style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" /> <img loading="lazy" src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
</div> </div>
修改密码 修改密码
</div> </div>
@@ -3,22 +3,22 @@
<div class="header-container"> <div class="header-container">
<!-- 左侧元素 --> <!-- 左侧元素 -->
<div class="header-left"> <div class="header-left">
<img alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/>
<img alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
</div> </div>
<!-- 中间导航菜单 --> <!-- 中间导航菜单 -->
<div class="header-center"> <div class="header-center">
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome"> <div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome">
<img alt="" src="@/assets/header/roboot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/> <img loading="lazy" alt="" src="@/assets/header/roboot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/>
智能体管理 智能体管理
</div> </div>
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement"> <div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement">
<img alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/> <img loading="lazy" alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/>
用户管理 用户管理
</div> </div>
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig"> <div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig">
<img alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/> <img loading="lazy" alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/>
模型配置 模型配置
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i> <i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
</el-input> </el-input>
</div> </div>
<img alt="" src="@/assets/home/avatar.png" class="avatar-img"/> <img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img"/>
<el-dropdown trigger="click" class="user-dropdown"> <el-dropdown trigger="click" class="user-dropdown">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
{{ userInfo.mobile || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i> {{ userInfo.mobile || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
+1
View File
@@ -5,6 +5,7 @@ import store from './store'
import 'normalize.css/normalize.css' // A modern alternative to CSS resets import 'normalize.css/normalize.css' // A modern alternative to CSS resets
import ElementUI from 'element-ui'; import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css'; import 'element-ui/lib/theme-chalk/index.css';
import './styles/global.scss'
Vue.use(ElementUI); Vue.use(ElementUI);
Vue.config.productionTip = false Vue.config.productionTip = false
+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>
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;"> <el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
<img alt="" src="@/assets/model/inner_conf.png"> <img loading="lazy" alt="" src="@/assets/model/inner_conf.png">
导入配置 导入配置
</el-button> </el-button>
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;"> <el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
<img alt="" src="@/assets/model/output_conf.png"> <img loading="lazy" alt="" src="@/assets/model/output_conf.png">
导出配置 导出配置
</el-button> </el-button>
</div> </div>
+7 -7
View File
@@ -4,15 +4,15 @@
<el-header> <el-header>
<div <div
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;"> style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div> </div>
</el-header> </el-header>
<el-main style="position: relative;"> <el-main style="position: relative;">
<div class="login-box"> <div class="login-box">
<div <div
style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;"> style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/> <img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">登录</div> <div class="login-text">登录</div>
<div class="login-welcome"> <div class="login-welcome">
WELCOME TO LOGIN WELCOME TO LOGIN
@@ -20,19 +20,19 @@
</div> </div>
<div style="padding: 0 30px;"> <div style="padding: 0 30px;">
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/username.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/> <el-input v-model="form.username" placeholder="请输入用户名"/>
</div> </div>
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/> <el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div> </div>
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;"> <div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;"> <div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img alt="" class="input-icon" src="@/assets/login/shield.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/> <el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div> </div>
<img v-if="captchaUrl" <img loading="lazy" v-if="captchaUrl"
:src="captchaUrl" :src="captchaUrl"
alt="验证码" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" style="width: 150px; height: 40px; cursor: pointer;"
+8 -8
View File
@@ -4,8 +4,8 @@
<!-- 保持相同的头部 --> <!-- 保持相同的头部 -->
<el-header> <el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;"> <div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div> </div>
</el-header> </el-header>
@@ -13,7 +13,7 @@
<div class="login-box"> <div class="login-box">
<!-- 修改标题部分 --> <!-- 修改标题部分 -->
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;"> <div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/> <img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">注册</div> <div class="login-text">注册</div>
<div class="login-welcome"> <div class="login-welcome">
WELCOME TO REGISTER WELCOME TO REGISTER
@@ -23,29 +23,29 @@
<div style="padding: 0 30px;"> <div style="padding: 0 30px;">
<!-- 用户名输入框 --> <!-- 用户名输入框 -->
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/username.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/> <el-input v-model="form.username" placeholder="请输入用户名"/>
</div> </div>
<!-- 密码输入框 --> <!-- 密码输入框 -->
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/> <el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div> </div>
<!-- 新增确认密码 --> <!-- 新增确认密码 -->
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/> <el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
</div> </div>
<!-- 验证码部分保持相同 --> <!-- 验证码部分保持相同 -->
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;"> <div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;"> <div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img alt="" class="input-icon" src="@/assets/login/shield.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/> <el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div> </div>
<img v-if="captchaUrl" <img loading="lazy" v-if="captchaUrl"
:src="captchaUrl" :src="captchaUrl"
alt="验证码" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" style="width: 150px; height: 40px; cursor: pointer;"
+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;"> style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
<div <div
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;"> style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/> <img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
</div> </div>
{{ form.agentName }} {{ form.agentName }}
</div> </div>
@@ -83,7 +83,7 @@
重制 重制
</div> </div>
<div class="clear-text"> <div class="clear-text">
<img src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/> <img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
保存配置后需要重启设备新的配置才会生效 保存配置后需要重启设备新的配置才会生效
</div> </div>
</div> </div>
+121 -132
View File
@@ -18,70 +18,68 @@
</template> </template>
<script> <script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import Recorder from 'opus-recorder'; import Recorder from 'opus-recorder';
import { OpusDecoder } from 'opus-decoder'; import { OpusDecoder } from 'opus-decoder';
export default { export default {
name: 'TestPage', name: 'TestPage',
setup() { data() {
const messages = ref([]); return {
const chatContainer = ref(null); messages: [],
const wsStatus = ref('disconnected'); wsStatus: 'disconnected',
const isRecording = ref(false); isRecording: false,
let ws = null; ws: null,
let recorder = null; recorder: null,
let stream = null; stream: null,
let audioContext = null; audioContext: null,
let sourceNode = null; sourceNode: null,
let audioDecoder = null; audioDecoder: null,
let audioBufferQueue = []; // 当前句子的音频缓冲区 audioBufferQueue: [], // 当前句子的音频缓冲区
let playbackQueue = []; // 播放队列,存储待播放的句子 playbackQueue: [], // 播放队列,存储待播放的句子
let isPlaying = false; isPlaying: false,
}
const connectWebSocket = () => { },
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址 methods: {
ws.binaryType = 'arraybuffer'; connectWebSocket() {
ws.onopen = () => { this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
wsStatus.value = 'connected'; this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.wsStatus = 'connected';
console.log('WebSocket 连接成功'); console.log('WebSocket 连接成功');
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' })); this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 }); this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
}; };
ws.onmessage = async (event) => { this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') { if (typeof event.data === 'string') {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg); console.log('收到文本消息:', msg);
if (msg.type === 'stt') { if (msg.type === 'stt') {
messages.value.push({ role: 'user', content: msg.text }); this.messages.push({ role: 'user', content: msg.text });
scrollToBottom(); this.scrollToBottom();
} else if (msg.type === 'llm') { } else if (msg.type === 'llm') {
messages.value.push({ role: 'assistant', content: msg.text }); this.messages.push({ role: 'assistant', content: msg.text });
scrollToBottom(); this.scrollToBottom();
} else if (msg.type === 'tts') { } else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') { if (msg.state === 'sentence_start') {
// 开始新句子,清空当前缓冲区 this.audioBufferQueue = [];
audioBufferQueue = []; const lastMessage = this.messages[this.messages.length - 1];
const lastMessage = messages.value[messages.value.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) { if (!lastMessage || lastMessage.content !== msg.text) {
messages.value.push({ role: 'assistant', content: msg.text }); this.messages.push({ role: 'assistant', content: msg.text });
scrollToBottom(); this.scrollToBottom();
} }
} else if (msg.state === 'sentence_end') { } else if (msg.state === 'sentence_end') {
// 句子结束,将当前缓冲区加入播放队列 if (this.audioBufferQueue.length > 0) {
if (audioBufferQueue.length > 0) { this.playbackQueue.push([...this.audioBufferQueue]);
playbackQueue.push([...audioBufferQueue]); this.audioBufferQueue = [];
audioBufferQueue = []; this.playNextInQueue();
playNextInQueue(); // 尝试播放队列中的下一句
} }
} else if (msg.state === 'stop') { } else if (msg.state === 'stop') {
console.log('TTS 任务结束'); console.log('TTS 任务结束');
// 确保所有剩余音频播放 if (this.audioBufferQueue.length > 0) {
if (audioBufferQueue.length > 0) { this.playbackQueue.push([...this.audioBufferQueue]);
playbackQueue.push([...audioBufferQueue]); this.audioBufferQueue = [];
audioBufferQueue = []; this.playNextInQueue();
playNextInQueue();
} }
} }
} else if (msg.type === 'hello') { } else if (msg.type === 'hello') {
@@ -96,18 +94,17 @@ export default {
console.log('音频帧前8字节:', frameHead); console.log('音频帧前8字节:', frameHead);
try { try {
const decoded = audioDecoder.decodeFrame(opusFrame); const decoded = this.audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded); console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) { if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0]; const pcmData = decoded.channelData[0];
if (pcmData.length > 0) { if (pcmData.length > 0) {
audioBufferQueue.push(pcmData); this.audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length); console.log('解码音频帧,PCM 数据长度:', pcmData.length);
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放 if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) { this.playbackQueue.push([...this.audioBufferQueue]);
playbackQueue.push([...audioBufferQueue]); this.audioBufferQueue = [];
audioBufferQueue = []; this.playNextInQueue();
playNextInQueue();
} }
} else { } else {
console.warn('解码成功,但 PCM 数据长度为 0'); console.warn('解码成功,但 PCM 数据长度为 0');
@@ -120,39 +117,39 @@ export default {
} }
} }
}; };
ws.onerror = (error) => { this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error); console.error('WebSocket 错误:', error);
wsStatus.value = 'error'; this.wsStatus = 'error';
}; };
ws.onclose = () => { this.ws.onclose = () => {
wsStatus.value = 'disconnected'; this.wsStatus = 'disconnected';
console.log('WebSocket 断开'); console.log('WebSocket 断开');
setTimeout(connectWebSocket, 1000); setTimeout(this.connectWebSocket, 1000);
}; };
}; },
scrollToBottom() {
const scrollToBottom = () => { this.$nextTick(() => {
nextTick(() => { if (this.$refs.chatContainer) {
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight; this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
}
}); });
}; },
async playNextInQueue() {
const playNextInQueue = async () => { if (this.isPlaying || this.playbackQueue.length === 0) {
if (isPlaying || playbackQueue.length === 0) { return;
return; // 正在播放或队列为空,等待下次触发
} }
isPlaying = true; this.isPlaying = true;
if (!audioContext || audioContext.state === 'closed') { if (!this.audioContext || this.audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
} }
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句 const pcmBuffers = this.playbackQueue.shift();
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0); const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
if (totalLength === 0) { if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放'); console.error('音频缓冲区总长度为 0,无法播放');
isPlaying = false; this.isPlaying = false;
playNextInQueue(); // 尝试播放下一句 this.playNextInQueue();
return; return;
} }
@@ -163,22 +160,21 @@ export default {
offset += pcm.length; offset += pcm.length;
} }
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000); const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm); audioBuffer.getChannelData(0).set(mergedPcm);
const source = audioContext.createBufferSource(); const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer; source.buffer = audioBuffer;
source.connect(audioContext.destination); source.connect(this.audioContext.destination);
source.onended = () => { source.onended = () => {
isPlaying = false; this.isPlaying = false;
console.log('音频播放结束'); console.log('音频播放结束');
playNextInQueue(); // 播放结束后继续下一句 this.playNextInQueue();
}; };
source.start(); source.start();
console.log('开始播放音频,总长度:', totalLength); console.log('开始播放音频,总长度:', totalLength);
}; },
stripOggContainer(data) {
const stripOggContainer = (data) => {
let arrayBuffer; let arrayBuffer;
if (data instanceof ArrayBuffer) { if (data instanceof ArrayBuffer) {
arrayBuffer = data; arrayBuffer = data;
@@ -227,23 +223,22 @@ export default {
} }
console.log('剥离后找到', frames.length, '个裸 Opus 帧'); console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames; return frames;
}; },
async initRecorder() {
const initRecorder = async () => {
console.log('开始初始化录音'); console.log('开始初始化录音');
try { try {
if (stream) { if (this.stream) {
stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
} }
stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', stream); console.log('获取麦克风权限成功,stream:', this.stream);
if (!audioContext || audioContext.state === 'closed') { if (!this.audioContext || this.audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
} }
sourceNode = audioContext.createMediaStreamSource(stream); this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
recorder = new Recorder({ this.recorder = new Recorder({
encoderPath: '/encoderWorker.min.js', encoderPath: '/encoderWorker.min.js',
sampleRate: 16000, sampleRate: 16000,
numberOfChannels: 1, numberOfChannels: 1,
@@ -252,17 +247,16 @@ export default {
encoderFrameSize: 60, encoderFrameSize: 60,
encoderBitRate: 24000, encoderBitRate: 24000,
monitorGain: 0, monitorGain: 0,
sourceNode: sourceNode, sourceNode: this.sourceNode,
ogg: false, ogg: false,
streamPages: false, streamPages: false,
maxFramesPerPage: 1, maxFramesPerPage: 1,
}); });
recorder.ondataavailable = (data) => { this.recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name); console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = stripOggContainer(data); const frames = this.stripOggContainer(data);
frames.forEach((frame, index) => { frames.forEach((frame, index) => {
const frameView = new DataView(frame);
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8))) const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0')) .map(b => b.toString(16).padStart(2, '0'))
.join(' '); .join(' ');
@@ -273,8 +267,8 @@ export default {
return; return;
} }
if (ws && ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
ws.send(frame); this.ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength); console.log('发送裸 Opus 帧:', frame.byteLength);
} else { } else {
console.warn('WebSocket 未连接,跳过发送'); console.warn('WebSocket 未连接,跳过发送');
@@ -282,16 +276,16 @@ export default {
}); });
}; };
recorder.onstart = () => { this.recorder.onstart = () => {
console.log('录音已启动'); console.log('录音已启动');
}; };
recorder.onstop = () => { this.recorder.onstop = () => {
console.log('录音停止'); console.log('录音停止');
stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
stream = null; this.stream = null;
sourceNode = null; this.sourceNode = null;
scrollToBottom(); this.scrollToBottom();
}; };
console.log('Recorder 初始化成功'); console.log('Recorder 初始化成功');
@@ -299,48 +293,43 @@ export default {
console.error('初始化录音失败:', err); console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限'); alert('无法访问麦克风或录音初始化失败,请检查权限');
} }
}; },
async toggleRecording() {
const toggleRecording = async () => { console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value); if (!this.recorder) {
if (!recorder) { await this.initRecorder();
await initRecorder(); if (!this.recorder) {
if (!recorder) {
console.error('recorder 初始化失败'); console.error('recorder 初始化失败');
return; return;
} }
} }
if (isRecording.value) { if (this.isRecording) {
console.log('停止录音'); console.log('停止录音');
recorder.stop(); this.recorder.stop();
isRecording.value = false; this.isRecording = false;
} else { } else {
try { try {
console.log('开始录音'); console.log('开始录音');
await initRecorder(); await this.initRecorder();
await recorder.start(); await this.recorder.start();
console.log('录音开始后,状态:', recorder.state); console.log('录音开始后,状态:', this.recorder.state);
isRecording.value = true; this.isRecording = true;
} catch (err) { } catch (err) {
console.error('录音启动失败:', err); console.error('录音启动失败:', err);
} }
} }
}; },
},
onMounted(() => { mounted() {
console.log('组件挂载,初始化 WebSocket'); console.log('组件挂载,初始化 WebSocket');
connectWebSocket(); this.connectWebSocket();
}); },
destroyed() {
onUnmounted(() => { if (this.ws) this.ws.close();
if (ws) ws.close(); if (this.stream) this.stream.getTracks().forEach(track => track.stop());
if (stream) stream.getTracks().forEach(track => track.stop()); if (this.recorder) this.recorder.stop();
if (recorder) recorder.stop(); if (this.audioContext) this.audioContext.close();
if (audioContext) audioContext.close(); if (this.audioDecoder) this.audioDecoder.destroy();
if (audioDecoder) audioDecoder.destroy();
});
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
} }
}; };
</script> </script>
+135 -16
View File
@@ -1,24 +1,143 @@
const { defineConfig } = require('@vue/cli-service'); const { defineConfig } = require('@vue/cli-service');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
// TerserPlugin 用于压缩 JavaScript
const TerserPlugin = require('terser-webpack-plugin');
// CompressionPlugin 开启 Gzip 压缩
const CompressionPlugin = require('compression-webpack-plugin')
// BundleAnalyzerPlugin 用于分析打包后的文件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// 引入 path 模块
const path = require('path');
// 确保加载 .env 文件 // 确保加载 .env 文件
dotenv.config(); dotenv.config();
module.exports = defineConfig({ module.exports = defineConfig({
devServer: { productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
// Bug 修复:将代理配置为环境变量中定义的 API 基础 URL devServer: {
port: 8001, // 指定端口为 8001 port: 8001, // 指定端口为 8001
proxy: { proxy: {
'/xiaozhi-esp32-api': { '/xiaozhi-esp32-api': {
target: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8002', // 后端 API 的基础 URL target: process.env.VUE_APP_API_BASE_URL, // 后端 API 的基础 URL
changeOrigin: true, // 允许跨域 changeOrigin: true, // 允许跨域
// pathRewrite: { pathRewrite: {
// '^/api': '', // 路径重写 '^/xiaozhi-esp32-api': '/xiaozhi-esp32-api',
// },
},
},
client: {
overlay: false,
}, },
}, },
},
client: {
overlay: false, // 不显示 webpack 错误覆盖层
},
},
chainWebpack: config => {
// 修改 HTML 插件配置,动态插入 CDN 链接
config.plugin('html')
.tap(args => {
if (process.env.NODE_ENV === 'production') {
args[0].cdn = {
css: [
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
js: [
'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js',
'https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js',
'https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js',
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js',
'https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js',
'https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/dist/opus-decoder.min.js'
]
};
}
return args;
});
// 代码分割优化
config.optimization.splitChunks({
chunks: 'all',
minSize: 20000,
maxSize: 250000,
cacheGroups: {
vendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial',
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true,
},
}
});
// 启用优化设置
config.optimization.usedExports(true);
config.optimization.concatenateModules(true);
config.optimization.minimize(true);
},
configureWebpack: config => {
if (process.env.NODE_ENV === 'production') {
// 开启多线程编译
config.optimization = {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log']
}
}
})
]
};
config.plugins.push(
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 20480,
minRatio: 0.8
})
);
config.externals = {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'element-ui': 'ELEMENT',
'axios': 'axios',
'opus-decoder': 'OpusDecoder'
};
if (process.env.ANALYZE === 'true') { // 通过环境变量控制
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'server', // 开启本地服务器模式
openAnalyzer: true, // 自动打开浏览器
analyzerPort: 8888 // 指定端口号
})
);
}
config.cache = {
type: 'filesystem', // 使用文件系统缓存
cacheDirectory: path.resolve(__dirname, '.webpack_cache'), // 自定义缓存目录
allowCollectingMemory: true, // 启用内存收集
compression: 'gzip', // 启用gzip压缩缓存
maxAge: 5184000000, // 缓存有效期为 1个月
buildDependencies: {
config: [__filename] // 每次配置文件修改时缓存失效
}
};
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'), // 让 '@' 代表 'src' 目录
'@assets': path.resolve(__dirname, 'src/assets'), // 设置 '@assets' 为 'src/assets' 目录
'@components': path.resolve(__dirname, 'src/components'), // 设置 '@components' 为 'src/components' 目录
'@views': path.resolve(__dirname, 'src/views'), // 设置 '@views' 为 'src/views' 目录
}
}
},
}); });