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
# Environments
.env
.venv
env/
venv/
@@ -150,10 +149,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/
+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,
"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"
]
}
+11 -1
View File
@@ -5,7 +5,12 @@
<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>
<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>
<body>
<noscript>
@@ -13,5 +18,10 @@
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<% if (htmlWebpackPlugin.options.cdn) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %>
<% } %>
</body>
</html>
+1 -1
View File
@@ -28,5 +28,5 @@ nav {
}
</style>
<script setup lang="ts">
<script>
</script>
+2 -2
View File
@@ -7,7 +7,7 @@ import admin from './module/admin.js'
* 如果你想调用8002端口,就用'/xiaozhi-esp32-api/api/v1',请与vue.config.js的devServer配置相结合,方便跨域请求
*
*/
const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
// const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
// 8002开发完成完成后使用这个
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
@@ -16,7 +16,7 @@ const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
* @returns {string}
*/
export function getServiceUrl() {
return DEV_API_SERVICE
return '/xiaozhi-esp32-api'
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

@@ -2,7 +2,7 @@
<el-dialog :visible.sync="visible" width="400px" center>
<div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
<img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
</div>
添加智慧体
</div>
@@ -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;">
<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>
@@ -3,22 +3,22 @@
<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/roboot.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>
@@ -35,7 +35,7 @@
<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>
+1
View File
@@ -5,6 +5,7 @@ import store from './store'
import 'normalize.css/normalize.css' // A modern alternative to CSS resets
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import './styles/global.scss'
Vue.use(ElementUI);
Vue.config.productionTip = false
+14
View File
@@ -0,0 +1,14 @@
// 覆盖 autofill 样式
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px transparent inset;
transition: background-color 5000s ease-in-out 0s;
background-color: transparent;
}
+2 -2
View File
@@ -9,11 +9,11 @@
语音设置
</el-button>
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
<img alt="" src="@/assets/model/inner_conf.png">
<img loading="lazy" alt="" src="@/assets/model/inner_conf.png">
导入配置
</el-button>
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
<img alt="" src="@/assets/model/output_conf.png">
<img loading="lazy" alt="" src="@/assets/model/output_conf.png">
导出配置
</el-button>
</div>
+7 -7
View File
@@ -4,15 +4,15 @@
<el-header>
<div
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div>
</el-header>
<el-main style="position: relative;">
<div class="login-box">
<div
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;"
+8 -8
View File
@@ -4,8 +4,8 @@
<!-- 保持相同的头部 -->
<el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div>
</el-header>
@@ -13,7 +13,7 @@
<div class="login-box">
<!-- 修改标题部分 -->
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">注册</div>
<div class="login-welcome">
WELCOME TO REGISTER
@@ -23,29 +23,29 @@
<div style="padding: 0 30px;">
<!-- 用户名输入框 -->
<div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/>
</div>
<!-- 密码输入框 -->
<div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div>
<!-- 新增确认密码 -->
<div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
</div>
<!-- 验证码部分保持相同 -->
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div>
<img v-if="captchaUrl"
<img loading="lazy" v-if="captchaUrl"
:src="captchaUrl"
alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;"
+2 -2
View File
@@ -7,7 +7,7 @@
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
<div
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
</div>
{{ form.agentName }}
</div>
@@ -83,7 +83,7 @@
重制
</div>
<div class="clear-text">
<img src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
保存配置后需要重启设备新的配置才会生效
</div>
</div>
+121 -132
View File
@@ -18,70 +18,68 @@
</template>
<script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import Recorder from 'opus-recorder';
import { OpusDecoder } from 'opus-decoder';
export default {
name: 'TestPage',
setup() {
const messages = ref([]);
const chatContainer = ref(null);
const wsStatus = ref('disconnected');
const isRecording = ref(false);
let ws = null;
let recorder = null;
let stream = null;
let audioContext = null;
let sourceNode = null;
let audioDecoder = null;
let audioBufferQueue = []; // 当前句子的音频缓冲区
let playbackQueue = []; // 播放队列,存储待播放的句子
let isPlaying = false;
const connectWebSocket = () => {
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
wsStatus.value = 'connected';
data() {
return {
messages: [],
wsStatus: 'disconnected',
isRecording: false,
ws: null,
recorder: null,
stream: null,
audioContext: null,
sourceNode: null,
audioDecoder: null,
audioBufferQueue: [], // 当前句子的音频缓冲区
playbackQueue: [], // 播放队列,存储待播放的句子
isPlaying: false,
}
},
methods: {
connectWebSocket() {
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.wsStatus = 'connected';
console.log('WebSocket 连接成功');
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
};
ws.onmessage = async (event) => {
this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg);
if (msg.type === 'stt') {
messages.value.push({ role: 'user', content: msg.text });
scrollToBottom();
this.messages.push({ role: 'user', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'llm') {
messages.value.push({ role: 'assistant', content: msg.text });
scrollToBottom();
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') {
// 开始新句子,清空当前缓冲区
audioBufferQueue = [];
const lastMessage = messages.value[messages.value.length - 1];
this.audioBufferQueue = [];
const lastMessage = this.messages[this.messages.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) {
messages.value.push({ role: 'assistant', content: msg.text });
scrollToBottom();
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
}
} else if (msg.state === 'sentence_end') {
// 句子结束,将当前缓冲区加入播放队列
if (audioBufferQueue.length > 0) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue(); // 尝试播放队列中的下一句
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else if (msg.state === 'stop') {
console.log('TTS 任务结束');
// 确保所有剩余音频播放
if (audioBufferQueue.length > 0) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue();
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
}
} else if (msg.type === 'hello') {
@@ -96,18 +94,17 @@ export default {
console.log('音频帧前8字节:', frameHead);
try {
const decoded = audioDecoder.decodeFrame(opusFrame);
const decoded = this.audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0];
if (pcmData.length > 0) {
audioBufferQueue.push(pcmData);
this.audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) {
playbackQueue.push([...audioBufferQueue]);
audioBufferQueue = [];
playNextInQueue();
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else {
console.warn('解码成功,但 PCM 数据长度为 0');
@@ -120,39 +117,39 @@ export default {
}
}
};
ws.onerror = (error) => {
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
wsStatus.value = 'error';
this.wsStatus = 'error';
};
ws.onclose = () => {
wsStatus.value = 'disconnected';
this.ws.onclose = () => {
this.wsStatus = 'disconnected';
console.log('WebSocket 断开');
setTimeout(connectWebSocket, 1000);
setTimeout(this.connectWebSocket, 1000);
};
};
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
},
scrollToBottom() {
this.$nextTick(() => {
if (this.$refs.chatContainer) {
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
}
});
};
const playNextInQueue = async () => {
if (isPlaying || playbackQueue.length === 0) {
return; // 正在播放或队列为空,等待下次触发
},
async playNextInQueue() {
if (this.isPlaying || this.playbackQueue.length === 0) {
return;
}
isPlaying = true;
this.isPlaying = true;
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句
const pcmBuffers = this.playbackQueue.shift();
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放');
isPlaying = false;
playNextInQueue(); // 尝试播放下一句
this.isPlaying = false;
this.playNextInQueue();
return;
}
@@ -163,22 +160,21 @@ export default {
offset += pcm.length;
}
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000);
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm);
const source = audioContext.createBufferSource();
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.connect(this.audioContext.destination);
source.onended = () => {
isPlaying = false;
this.isPlaying = false;
console.log('音频播放结束');
playNextInQueue(); // 播放结束后继续下一句
this.playNextInQueue();
};
source.start();
console.log('开始播放音频,总长度:', totalLength);
};
const stripOggContainer = (data) => {
},
stripOggContainer(data) {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
@@ -227,23 +223,22 @@ export default {
}
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames;
};
const initRecorder = async () => {
},
async initRecorder() {
console.log('开始初始化录音');
try {
if (stream) {
stream.getTracks().forEach(track => track.stop());
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', stream);
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', this.stream);
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
sourceNode = audioContext.createMediaStreamSource(stream);
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
recorder = new Recorder({
this.recorder = new Recorder({
encoderPath: '/encoderWorker.min.js',
sampleRate: 16000,
numberOfChannels: 1,
@@ -252,17 +247,16 @@ export default {
encoderFrameSize: 60,
encoderBitRate: 24000,
monitorGain: 0,
sourceNode: sourceNode,
sourceNode: this.sourceNode,
ogg: false,
streamPages: false,
maxFramesPerPage: 1,
});
recorder.ondataavailable = (data) => {
this.recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = stripOggContainer(data);
const frames = this.stripOggContainer(data);
frames.forEach((frame, index) => {
const frameView = new DataView(frame);
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
@@ -273,8 +267,8 @@ export default {
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(frame);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength);
} else {
console.warn('WebSocket 未连接,跳过发送');
@@ -282,16 +276,16 @@ export default {
});
};
recorder.onstart = () => {
this.recorder.onstart = () => {
console.log('录音已启动');
};
recorder.onstop = () => {
this.recorder.onstop = () => {
console.log('录音停止');
stream.getTracks().forEach(track => track.stop());
stream = null;
sourceNode = null;
scrollToBottom();
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
this.sourceNode = null;
this.scrollToBottom();
};
console.log('Recorder 初始化成功');
@@ -299,48 +293,43 @@ export default {
console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限');
}
};
const toggleRecording = async () => {
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value);
if (!recorder) {
await initRecorder();
if (!recorder) {
},
async toggleRecording() {
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
if (!this.recorder) {
await this.initRecorder();
if (!this.recorder) {
console.error('recorder 初始化失败');
return;
}
}
if (isRecording.value) {
if (this.isRecording) {
console.log('停止录音');
recorder.stop();
isRecording.value = false;
this.recorder.stop();
this.isRecording = false;
} else {
try {
console.log('开始录音');
await initRecorder();
await recorder.start();
console.log('录音开始后,状态:', recorder.state);
isRecording.value = true;
await this.initRecorder();
await this.recorder.start();
console.log('录音开始后,状态:', this.recorder.state);
this.isRecording = true;
} catch (err) {
console.error('录音启动失败:', err);
}
}
};
onMounted(() => {
console.log('组件挂载,初始化 WebSocket');
connectWebSocket();
});
onUnmounted(() => {
if (ws) ws.close();
if (stream) stream.getTracks().forEach(track => track.stop());
if (recorder) recorder.stop();
if (audioContext) audioContext.close();
if (audioDecoder) audioDecoder.destroy();
});
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
},
},
mounted() {
console.log('组件挂载,初始化 WebSocket');
this.connectWebSocket();
},
destroyed() {
if (this.ws) this.ws.close();
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
if (this.recorder) this.recorder.stop();
if (this.audioContext) this.audioContext.close();
if (this.audioDecoder) this.audioDecoder.destroy();
}
};
</script>
+135 -16
View File
@@ -1,24 +1,143 @@
const { defineConfig } = require('@vue/cli-service');
const dotenv = require('dotenv');
// TerserPlugin 用于压缩 JavaScript
const TerserPlugin = require('terser-webpack-plugin');
// CompressionPlugin 开启 Gzip 压缩
const CompressionPlugin = require('compression-webpack-plugin')
// BundleAnalyzerPlugin 用于分析打包后的文件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// 引入 path 模块
const path = require('path');
// 确保加载 .env 文件
dotenv.config();
module.exports = defineConfig({
devServer: {
// Bug 修复:将代理配置为环境变量中定义的 API 基础 URL
port: 8001, // 指定端口为 8001
proxy: {
'/xiaozhi-esp32-api': {
target: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8002', // 后端 API 的基础 URL
changeOrigin: true, // 允许跨域
// pathRewrite: {
// '^/api': '', // 路径重写
// },
},
},
client: {
overlay: false,
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
devServer: {
port: 8001, // 指定端口为 8001
proxy: {
'/xiaozhi-esp32-api': {
target: process.env.VUE_APP_API_BASE_URL, // 后端 API 的基础 URL
changeOrigin: true, // 允许跨域
pathRewrite: {
'^/xiaozhi-esp32-api': '/xiaozhi-esp32-api',
},
},
},
client: {
overlay: false, // 不显示 webpack 错误覆盖层
},
},
chainWebpack: config => {
// 修改 HTML 插件配置,动态插入 CDN 链接
config.plugin('html')
.tap(args => {
if (process.env.NODE_ENV === 'production') {
args[0].cdn = {
css: [
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
js: [
'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js',
'https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js',
'https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js',
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js',
'https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js',
'https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/dist/opus-decoder.min.js'
]
};
}
return args;
});
// 代码分割优化
config.optimization.splitChunks({
chunks: 'all',
minSize: 20000,
maxSize: 250000,
cacheGroups: {
vendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial',
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true,
},
}
});
// 启用优化设置
config.optimization.usedExports(true);
config.optimization.concatenateModules(true);
config.optimization.minimize(true);
},
configureWebpack: config => {
if (process.env.NODE_ENV === 'production') {
// 开启多线程编译
config.optimization = {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log']
}
}
})
]
};
config.plugins.push(
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 20480,
minRatio: 0.8
})
);
config.externals = {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'element-ui': 'ELEMENT',
'axios': 'axios',
'opus-decoder': 'OpusDecoder'
};
if (process.env.ANALYZE === 'true') { // 通过环境变量控制
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'server', // 开启本地服务器模式
openAnalyzer: true, // 自动打开浏览器
analyzerPort: 8888 // 指定端口号
})
);
}
config.cache = {
type: 'filesystem', // 使用文件系统缓存
cacheDirectory: path.resolve(__dirname, '.webpack_cache'), // 自定义缓存目录
allowCollectingMemory: true, // 启用内存收集
compression: 'gzip', // 启用gzip压缩缓存
maxAge: 5184000000, // 缓存有效期为 1个月
buildDependencies: {
config: [__filename] // 每次配置文件修改时缓存失效
}
};
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'), // 让 '@' 代表 'src' 目录
'@assets': path.resolve(__dirname, 'src/assets'), // 设置 '@assets' 为 'src/assets' 目录
'@components': path.resolve(__dirname, 'src/components'), // 设置 '@components' 为 'src/components' 目录
'@views': path.resolve(__dirname, 'src/views'), // 设置 '@views' 为 'src/views' 目录
}
}
},
});