Compare commits
5 Commits
19aa7a1f8f
...
feat/lqp/1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309b1318a7 | ||
|
|
7fe4b05cf8 | ||
|
|
792fa980f9 | ||
| 6087a3f195 | |||
| 8d861d5b6f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -72,4 +72,5 @@ coverage/
|
||||
# UniApp specific
|
||||
unpackage/
|
||||
dist/
|
||||
.history/
|
||||
.history/
|
||||
purple-energy-visualizer/
|
||||
269
TEST_MODE_GUIDE.md
Normal file
269
TEST_MODE_GUIDE.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 🧪 WebSocket语音流测试模式使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
测试模式允许你在不使用实际录音功能的情况下测试WebSocket语音流式对话功能。这对于在不支持录音的环境(如某些微信小程序开发环境)或需要使用特定测试数据的场景非常有用。
|
||||
|
||||
## 启用测试模式
|
||||
|
||||
在 `webUI/src/utils/config.js` 中配置:
|
||||
|
||||
```javascript
|
||||
TEST_MODE: {
|
||||
enabled: true, // 设置为 true 启用测试模式,false 使用正常录音
|
||||
audioDuration: 3, // 测试音频时长(秒)
|
||||
sampleRate: 16000, // 采样率(Hz)
|
||||
channels: 1, // 声道数(1=单声道,2=立体声)
|
||||
bitDepth: 16 // 位深度(8或16)
|
||||
}
|
||||
```
|
||||
|
||||
## 测试模式特性
|
||||
|
||||
### 启用时(enabled: true)
|
||||
- ✅ 显示 "🧪 发送测试音频" 按钮
|
||||
- ✅ 禁用自动录音功能
|
||||
- ✅ 点击按钮发送假数据到WebSocket
|
||||
- ✅ 页面提示 "测试模式:点击发送假数据"
|
||||
|
||||
### 禁用时(enabled: false)
|
||||
- ✅ 隐藏测试按钮
|
||||
- ✅ 启用正常录音功能
|
||||
- ✅ 自动模式下自动开始录音
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. **启用测试模式**
|
||||
```javascript
|
||||
// 在 config.js 中
|
||||
TEST_MODE: { enabled: true }
|
||||
```
|
||||
|
||||
2. **进入语音模式**
|
||||
- 点击输入框右侧的 🎧 图标切换到语音模式
|
||||
|
||||
3. **发送测试数据**
|
||||
- 点击 "🧪 发送测试音频" 按钮
|
||||
- 系统会自动生成假PCM数据并发送到WebSocket服务器
|
||||
|
||||
4. **观察结果**
|
||||
- STT识别结果会显示在聊天界面
|
||||
- AI回复会实时流式显示
|
||||
- TTS音频会实时播放
|
||||
|
||||
## PCM音频数据格式说明
|
||||
|
||||
### 当前生成的测试数据格式
|
||||
|
||||
代码会自动生成符合以下规格的PCM数据:
|
||||
|
||||
```javascript
|
||||
{
|
||||
采样率: 16000 Hz, // 16kHz,语音常用采样率
|
||||
位深度: 16 bit, // 16位有符号整数
|
||||
声道数: 1, // 单声道
|
||||
字节序: Little Endian, // 小端序
|
||||
时长: 3秒,
|
||||
数据大小: 96000 bytes // = 16000 * 3 * 2
|
||||
}
|
||||
```
|
||||
|
||||
### 测试数据生成原理
|
||||
|
||||
代码生成的是一个模拟人声的音频信号:
|
||||
- **基频**: 200Hz(人声范围)
|
||||
- **泛音**: 包含2次和3次谐波,让声音更丰富
|
||||
- **噪声**: 添加轻微随机噪声,模拟呼吸音
|
||||
- **包络**: 淡入淡出效果,让声音更自然
|
||||
|
||||
### 如果需要使用真实音频文件
|
||||
|
||||
如果你想使用真实的音频文件进行测试,需要先将音频转换为PCM格式:
|
||||
|
||||
#### 1. 使用FFmpeg转换
|
||||
|
||||
```bash
|
||||
# 从MP3/WAV转换为PCM
|
||||
ffmpeg -i input.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 output.pcm
|
||||
|
||||
# 参数说明:
|
||||
# -f s16le: 输出格式为16位小端序PCM
|
||||
# -acodec pcm_s16le: 使用16位PCM编码
|
||||
# -ar 16000: 采样率16kHz
|
||||
# -ac 1: 单声道
|
||||
```
|
||||
|
||||
#### 2. 在代码中使用真实PCM文件
|
||||
|
||||
修改 `ChatBox.vue` 中的 `generateTestPCMAudio()` 函数:
|
||||
|
||||
```javascript
|
||||
// 方案A: 直接读取PCM文件
|
||||
const generateTestPCMAudio = async () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager();
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile({
|
||||
filePath: '/static/test_audio.pcm', // 你的PCM文件路径
|
||||
success: (res) => {
|
||||
resolve(res.data); // res.data 是 ArrayBuffer
|
||||
},
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
};
|
||||
|
||||
// 方案B: 从base64字符串加载
|
||||
const TEST_PCM_BASE64 = "你的base64编码的PCM数据...";
|
||||
const generateTestPCMAudio = () => {
|
||||
return uni.base64ToArrayBuffer(TEST_PCM_BASE64);
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. 获取音频的base64编码
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
base64 output.pcm > output_base64.txt
|
||||
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("output.pcm")) > output_base64.txt
|
||||
```
|
||||
|
||||
然后将base64字符串复制到代码中。
|
||||
|
||||
## PCM数据格式要求
|
||||
|
||||
### 必须满足的要求
|
||||
|
||||
✅ **格式**: 原始PCM数据(无文件头)
|
||||
✅ **采样率**: 16000 Hz(后端STT服务要求)
|
||||
✅ **位深度**: 16 bit
|
||||
✅ **声道数**: 1(单声道)
|
||||
✅ **字节序**: Little Endian(小端序)
|
||||
✅ **编码**: 有符号整数(Signed Integer)
|
||||
|
||||
### 数据大小计算
|
||||
|
||||
```
|
||||
数据大小(字节)= 采样率 × 时长(秒)× 声道数 × (位深度/8)
|
||||
|
||||
示例:3秒的音频
|
||||
= 16000 × 3 × 1 × 2
|
||||
= 96000 bytes
|
||||
= 93.75 KB
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ **测试数据限制**
|
||||
- 生成的测试数据是简单的合成音,不包含真实语音内容
|
||||
- STT识别结果可能为空或乱码(取决于STT服务对合成音的处理)
|
||||
- 如需测试真实识别,请使用真实录音的PCM数据
|
||||
|
||||
⚠️ **WebSocket模式要求**
|
||||
- 测试模式需要配合WebSocket模式使用
|
||||
- 确保在 `ChatBox.vue` 中设置 `isWebSocketMode.value = true`
|
||||
- 确保WebSocket服务器正在运行
|
||||
|
||||
⚠️ **性能考虑**
|
||||
- 较长的音频文件会占用更多内存
|
||||
- 建议测试音频时长不超过5秒
|
||||
- 如需长时间测试,可以循环发送短音频
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 查看浏览器控制台
|
||||
|
||||
测试模式会输出详细日志:
|
||||
|
||||
```
|
||||
[TestMode] 生成测试PCM音频: {
|
||||
sampleRate: 16000,
|
||||
duration: "3秒",
|
||||
channels: 1,
|
||||
bitDepth: "16位",
|
||||
dataSize: "96000 bytes",
|
||||
frequency: "200 Hz"
|
||||
}
|
||||
[TestMode] 开始发送测试音频数据
|
||||
[VoiceMode] 测试音频已通过WebSocket发送
|
||||
```
|
||||
|
||||
### 验证数据格式
|
||||
|
||||
在浏览器控制台检查:
|
||||
|
||||
```javascript
|
||||
// 检查生成的数据
|
||||
const data = generateTestPCMAudio();
|
||||
console.log('数据类型:', data instanceof ArrayBuffer);
|
||||
console.log('数据大小:', data.byteLength, 'bytes');
|
||||
console.log('预期大小:', 16000 * 3 * 2, 'bytes');
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题1: 点击按钮无反应
|
||||
- 检查是否已进入语音模式(点击🎧图标)
|
||||
- 检查是否启用了WebSocket模式
|
||||
- 查看浏览器控制台是否有错误
|
||||
|
||||
### 问题2: STT识别结果为空
|
||||
- 这是正常的,合成音不包含真实语音内容
|
||||
- 使用真实PCM录音文件进行测试
|
||||
|
||||
### 问题3: WebSocket连接失败
|
||||
- 检查后端服务是否运行
|
||||
- 检查 `config.js` 中的 `WS_BASE_URL` 配置
|
||||
- 检查网络连接和防火墙设置
|
||||
|
||||
### 问题4: 音频无法播放
|
||||
- 检查返回的TTS音频格式
|
||||
- 查看浏览器控制台的音频播放日志
|
||||
- 确认播放权限已授予
|
||||
|
||||
## 示例代码
|
||||
|
||||
### 完整的测试流程示例
|
||||
|
||||
```javascript
|
||||
// 1. 配置测试模式
|
||||
// config.js
|
||||
export const API_CONFIG = {
|
||||
TEST_MODE: {
|
||||
enabled: true,
|
||||
audioDuration: 3,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
bitDepth: 16
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 进入语音模式
|
||||
toggleVoiceMode(); // 点击🎧图标
|
||||
|
||||
// 3. 发送测试数据
|
||||
sendTestAudio(); // 点击测试按钮
|
||||
|
||||
// 4. 观察回调
|
||||
voiceStreamWs.on('sttResult', (text) => {
|
||||
console.log('识别结果:', text);
|
||||
});
|
||||
|
||||
voiceStreamWs.on('sentence', (sentence) => {
|
||||
console.log('完整句子:', sentence);
|
||||
});
|
||||
|
||||
voiceStreamWs.on('audioChunk', (audioData) => {
|
||||
console.log('收到音频:', audioData.byteLength, 'bytes');
|
||||
});
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
测试模式为你提供了一个便捷的方式来测试WebSocket语音流功能,无需实际录音设备。通过生成符合格式的假PCM数据,你可以验证整个语音对话流程的正确性。
|
||||
|
||||
如有任何问题,请检查浏览器控制台的日志输出,那里会有详细的调试信息。
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060420250429001",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"pinia": "^2.1.7",
|
||||
"three": "^0.181.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,10 +103,10 @@
|
||||
"backgroundColor": "#F8F8F8"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "rgba(255, 255, 255, 0.5)",
|
||||
"color": "#999999",
|
||||
"selectedColor": "#f9e076",
|
||||
"backgroundColor": "rgba(26, 11, 46, 0.95)",
|
||||
"borderStyle": "white",
|
||||
"backgroundColor": "#1a0b2e",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/drama/index",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<ChatBox
|
||||
:character-config="characterConfig"
|
||||
:ai-config="aiConfig"
|
||||
:ui-config="uiConfig"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -20,11 +19,6 @@ const aiConfig = ref({
|
||||
templateId: 9999
|
||||
});
|
||||
|
||||
// UI 配置
|
||||
const uiConfig = ref({
|
||||
showBackButton: false // 默认不显示返回按钮
|
||||
});
|
||||
|
||||
// 初始化:解析 URL 参数
|
||||
onMounted(() => {
|
||||
|
||||
@@ -32,18 +26,13 @@ onMounted(() => {
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const options = currentPage.options || {};
|
||||
|
||||
// 判断是否需要显示返回按钮
|
||||
if (options.characterId || options.roleId) {
|
||||
uiConfig.value.showBackButton = true;
|
||||
}
|
||||
|
||||
// 场景1: 蔚AI(characterId === 'wei-ai' 或无参数)
|
||||
if (options.characterId === 'wei-ai' || !options.characterId) {
|
||||
characterConfig.value = {
|
||||
id: 'wei-ai',
|
||||
roleId: null,
|
||||
name: options.characterName || '蔚AI',
|
||||
avatar: options.characterAvatar || '/file/avatar/2025/11/09/ccfc630120114984b9f2d6e4990f5cd8.jpg',
|
||||
avatar: options.characterAvatar || '/file/background/2025/11/11/97683faa11074a4bbe7619c246754c1a.jpg',
|
||||
greeting: options.introMessage ? decodeURIComponent(options.introMessage) : '你好!我是你的虚拟女友!',
|
||||
roleDesc: ''
|
||||
};
|
||||
@@ -127,8 +116,7 @@ onMounted(() => {
|
||||
|
||||
console.log('chat-box 页面初始化完成:', {
|
||||
characterConfig: characterConfig.value,
|
||||
aiConfig: aiConfig.value,
|
||||
uiConfig: uiConfig.value
|
||||
aiConfig: aiConfig.value
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,8 +23,19 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备信息卡片 -->
|
||||
<view class="device-card" :style="{ marginTop: navBarHeight + 14 + 'px' }">
|
||||
<!-- 可滚动内容区域 -->
|
||||
<scroll-view
|
||||
class="scroll-container"
|
||||
:style="{ marginTop: navBarHeight + 'px' }"
|
||||
scroll-y="true"
|
||||
:show-scrollbar="false"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refresherTriggered"
|
||||
@refresherrefresh="onRefresh"
|
||||
refresher-background="rgba(26, 11, 46, 0.5)"
|
||||
>
|
||||
<!-- 设备信息卡片 -->
|
||||
<view class="device-card" style="margin-top: 14px;">
|
||||
<view class="device-header">
|
||||
<view class="device-icon" :class="{'connected': deviceConnected}">
|
||||
{{ deviceConnected ? '🌟' : '⭕' }}
|
||||
@@ -103,6 +114,10 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
<view class="bottom-spacing"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -122,6 +137,9 @@ const connectTime = ref('');
|
||||
const statusBarHeight = ref(0);
|
||||
const navBarHeight = ref(0);
|
||||
|
||||
// 下拉刷新相关
|
||||
const refresherTriggered = ref(false);
|
||||
|
||||
// 获取系统状态栏高度
|
||||
const getSystemInfo = () => {
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
@@ -139,7 +157,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
// 刷新设备状态
|
||||
const refreshDeviceStatus = () => {
|
||||
const refreshDeviceStatus = (isRefresh = false) => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({
|
||||
title: '请先登录',
|
||||
@@ -149,10 +167,12 @@ const refreshDeviceStatus = () => {
|
||||
}
|
||||
|
||||
// 暂时注释掉API请求,使用本地模拟数据
|
||||
uni.showToast({
|
||||
title: '刷新成功(本地模拟)',
|
||||
icon: 'success'
|
||||
});
|
||||
if (!isRefresh) {
|
||||
uni.showToast({
|
||||
title: '刷新成功(本地模拟)',
|
||||
icon: 'success'
|
||||
});
|
||||
}
|
||||
|
||||
/* API请求已注释
|
||||
uni.showLoading({
|
||||
@@ -283,15 +303,42 @@ const goToLogin = () => {
|
||||
url: '/pages/mine/mine'
|
||||
});
|
||||
};
|
||||
|
||||
// 下拉刷新处理
|
||||
const onRefresh = async () => {
|
||||
console.log('触发下拉刷新');
|
||||
refresherTriggered.value = true;
|
||||
|
||||
try {
|
||||
// 刷新设备状态
|
||||
refreshDeviceStatus(true);
|
||||
|
||||
// 延迟显示刷新成功提示
|
||||
setTimeout(() => {
|
||||
uni.showToast({
|
||||
title: '刷新成功',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
}, 300);
|
||||
} finally {
|
||||
// 刷新完成后,关闭刷新状态
|
||||
setTimeout(() => {
|
||||
refresherTriggered.value = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.floral-container {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a0b2e 0%, #2d1b4e 50%, #1a0b2e 100%);
|
||||
padding-bottom: 100rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 夜空装饰 */
|
||||
@@ -392,16 +439,24 @@ const goToLogin = () => {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 设备卡片 */
|
||||
.device-card {
|
||||
margin: 0 30rpx 40rpx 30rpx;
|
||||
margin: 0 30rpx 20rpx 30rpx;
|
||||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.1) 0%, rgba(249, 224, 118, 0.05) 100%);
|
||||
border-radius: 30rpx;
|
||||
padding: 40rpx;
|
||||
border: 2rpx solid rgba(249, 224, 118, 0.2);
|
||||
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
@@ -556,9 +611,8 @@ const goToLogin = () => {
|
||||
|
||||
/* 功能列表 */
|
||||
.feature-list {
|
||||
margin: 40rpx 30rpx;
|
||||
margin: 20rpx 30rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@@ -602,4 +656,9 @@ const goToLogin = () => {
|
||||
font-size: 24rpx;
|
||||
color: rgba(249, 224, 118, 0.6);
|
||||
}
|
||||
|
||||
/* 底部留白 */
|
||||
.bottom-spacing {
|
||||
height: 100rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
</view>
|
||||
|
||||
<!-- 可滚动的两列卡片区 -->
|
||||
<!-- 下拉刷新已禁用 -->
|
||||
<scroll-view
|
||||
class="scroll-container"
|
||||
:style="{ marginTop: navBarHeight + 'px' }"
|
||||
scroll-y="true"
|
||||
:show-scrollbar="false"
|
||||
:refresher-enabled="false"
|
||||
>
|
||||
<view class="two-column-grid">
|
||||
<view class="column column-left">
|
||||
@@ -36,13 +38,30 @@
|
||||
<view class="floral-grid-card" v-if="item">
|
||||
<view class="cover-container">
|
||||
<image class="cover" :src="getResourceUrl(item.cover)" mode="aspectFill" />
|
||||
<!-- 🔴 新增:未读消息红点 -->
|
||||
<view class="unread-badge" v-if="unreadCounts[item.roleId] > 0">
|
||||
{{ unreadCounts[item.roleId] }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="floral-tag">{{ item.tag }}</view>
|
||||
<view class="content-area">
|
||||
<view class="title">{{ item.title }}</view>
|
||||
<view class="card-bottom">
|
||||
<button v-if="userStore.isLoggedIn" class="floral-btn use-btn" @click="showDetail(item)">🔍 查看详情</button>
|
||||
<button v-else class="floral-btn outline use-btn login-required" @click="showLoginTip">🔐 查看详情</button>
|
||||
<button
|
||||
v-if="userStore.isLoggedIn"
|
||||
class="floral-btn use-btn"
|
||||
@click="showDetail(item)"
|
||||
@longpress="() => handleLongPress(item)"
|
||||
>
|
||||
🔍 查看详情
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="floral-btn outline use-btn login-required"
|
||||
@click="showLoginTip"
|
||||
>
|
||||
🔐 查看详情
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -54,13 +73,30 @@
|
||||
<view class="floral-grid-card" v-if="item">
|
||||
<view class="cover-container">
|
||||
<image class="cover" :src="getResourceUrl(item.cover)" mode="aspectFill" />
|
||||
<!-- 🔴 新增:未读消息红点 -->
|
||||
<view class="unread-badge" v-if="unreadCounts[item.roleId] > 0">
|
||||
{{ unreadCounts[item.roleId] }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="floral-tag">{{ item.tag }}</view>
|
||||
<view class="content-area">
|
||||
<view class="title">{{ item.title }}</view>
|
||||
<view class="card-bottom">
|
||||
<button v-if="userStore.isLoggedIn" class="floral-btn use-btn" @click="showDetail(item)">🔍 查看详情</button>
|
||||
<button v-else class="floral-btn outline use-btn login-required" @click="showLoginTip">🔐 查看详情</button>
|
||||
<button
|
||||
v-if="userStore.isLoggedIn"
|
||||
class="floral-btn use-btn"
|
||||
@click="showDetail(item)"
|
||||
@longpress="() => handleLongPress(item)"
|
||||
>
|
||||
🔍 查看详情
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="floral-btn outline use-btn login-required"
|
||||
@click="showLoginTip"
|
||||
>
|
||||
🔐 查看详情
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -117,6 +153,13 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { roleAPI, getResourceUrl } from '@/utils/api.js';
|
||||
// 🔴 新增:导入未读消息管理模块
|
||||
import {
|
||||
getAllUnreadCounts,
|
||||
generateTestMessages,
|
||||
clearUnreadMessages,
|
||||
autoGenerateMessagesForRandomRoles
|
||||
} from '@/utils/unreadMessages.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const showLoginModal = ref(false);
|
||||
@@ -125,10 +168,19 @@ const showLoginModal = ref(false);
|
||||
const showDetailModal = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
|
||||
// 下拉刷新相关
|
||||
const refresherTriggered = ref(false);
|
||||
|
||||
// 状态栏高度适配
|
||||
const statusBarHeight = ref(0);
|
||||
const navBarHeight = ref(0);
|
||||
|
||||
// 🔴 新增:未读消息数量统计
|
||||
const unreadCounts = ref({});
|
||||
|
||||
// 🔴 新增:是否首次加载标记
|
||||
const isFirstLoad = ref(true);
|
||||
|
||||
// 获取系统状态栏高度
|
||||
const getSystemInfo = () => {
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
@@ -320,11 +372,20 @@ const dramaList = ref([
|
||||
const leftColumnItems = computed(() => dramaList.value.filter((_, index) => index % 2 === 0));
|
||||
const rightColumnItems = computed(() => dramaList.value.filter((_, index) => index % 2 === 1));
|
||||
|
||||
// 🔴 新增:加载未读消息数量
|
||||
const loadUnreadCounts = () => {
|
||||
unreadCounts.value = getAllUnreadCounts();
|
||||
console.log('📬 未读消息统计:', unreadCounts.value);
|
||||
};
|
||||
|
||||
// 加载角色列表数据
|
||||
const loadDramaList = async () => {
|
||||
const loadDramaList = async (isRefresh = false) => {
|
||||
try {
|
||||
uni.showLoading({ title: '加载中...' });
|
||||
console.log('开始加载角色列表...');
|
||||
// 如果不是下拉刷新,则显示loading提示
|
||||
if (!isRefresh) {
|
||||
uni.showLoading({ title: '加载中...' });
|
||||
}
|
||||
console.log('开始加载角色列表...', isRefresh ? '(下拉刷新)' : '');
|
||||
|
||||
const result = await roleAPI.getRoles();
|
||||
console.log('API返回结果:', result);
|
||||
@@ -379,32 +440,93 @@ const loadDramaList = async () => {
|
||||
}));
|
||||
|
||||
console.log('角色列表加载成功,共', dramaList.value.length, '个角色');
|
||||
|
||||
// 如果是下拉刷新,显示成功提示
|
||||
if (isRefresh) {
|
||||
uni.showToast({
|
||||
title: '刷新成功',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('获取角色列表失败:', result.error);
|
||||
uni.showToast({
|
||||
title: '加载失败,请重试',
|
||||
title: isRefresh ? '刷新失败,请重试' : '加载失败,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载角色列表异常:', error);
|
||||
uni.showToast({
|
||||
title: '加载异常,请重试',
|
||||
title: isRefresh ? '刷新异常,请重试' : '加载异常,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
if (!isRefresh) {
|
||||
uni.hideLoading();
|
||||
}
|
||||
// 🔴 新增:加载完角色后刷新未读消息数量
|
||||
loadUnreadCounts();
|
||||
}
|
||||
};
|
||||
|
||||
// 下拉刷新处理
|
||||
const onRefresh = async () => {
|
||||
console.log('触发下拉刷新');
|
||||
refresherTriggered.value = true;
|
||||
|
||||
try {
|
||||
await loadDramaList(true);
|
||||
} finally {
|
||||
// 刷新完成后,关闭刷新状态
|
||||
setTimeout(() => {
|
||||
refresherTriggered.value = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔴 新增:自动生成随机角色消息
|
||||
const autoGenerateRandomMessages = () => {
|
||||
console.log('🎲 准备自动生成消息,当前角色数量:', dramaList.value.length);
|
||||
|
||||
if (dramaList.value.length === 0) {
|
||||
console.log('⚠️ 角色列表为空,无法生成消息');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 随机选择2个角色生成消息
|
||||
const count = autoGenerateMessagesForRandomRoles(dramaList.value, 2, 1);
|
||||
|
||||
if (count > 0) {
|
||||
console.log(`✨ 已为 ${count} 个随机角色生成主动消息`);
|
||||
// 刷新未读消息数量
|
||||
loadUnreadCounts();
|
||||
} else {
|
||||
console.log('⚠️ 未能生成消息');
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getSystemInfo(); // 获取系统信息
|
||||
userStore.init();
|
||||
|
||||
// 调用 API 加载角色列表
|
||||
loadDramaList();
|
||||
console.log('📱 进入角色列表页面');
|
||||
|
||||
// 刷新未读消息数量
|
||||
loadUnreadCounts();
|
||||
|
||||
// 延迟生成消息(等待角色列表加载完成)
|
||||
setTimeout(() => {
|
||||
autoGenerateRandomMessages();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
|
||||
// 方法
|
||||
const handleUse = (item) => {
|
||||
if (!item || !item.id) {
|
||||
@@ -412,7 +534,7 @@ const handleUse = (item) => {
|
||||
return;
|
||||
}
|
||||
uni.showLoading({ title: '正在设置角色...' });
|
||||
|
||||
|
||||
// 构建完整的角色参数,包括模型和模板信息
|
||||
const params = {
|
||||
characterId: item.id,
|
||||
@@ -428,15 +550,40 @@ const handleUse = (item) => {
|
||||
temperature: item.temperature || '',
|
||||
topP: item.topP || ''
|
||||
};
|
||||
|
||||
|
||||
const queryString = Object.keys(params)
|
||||
.map(key => `${key}=${encodeURIComponent(params[key] || '')}`)
|
||||
.join('&');
|
||||
|
||||
|
||||
uni.hideLoading();
|
||||
uni.navigateTo({ url: `/pages/role-chat/role-chat?${queryString}` });
|
||||
};
|
||||
|
||||
// 🔴 新增:长按角色卡生成测试消息(用于开发测试)
|
||||
const handleLongPress = (item) => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['为该角色生成1条消息', '为该角色生成3条消息'],
|
||||
success: (res) => {
|
||||
const messageCount = res.tapIndex === 0 ? 1 : 3;
|
||||
|
||||
const success = generateTestMessages(item.roleId, item.roleName, messageCount);
|
||||
if (success) {
|
||||
loadUnreadCounts(); // 刷新未读消息数量
|
||||
uni.showToast({
|
||||
title: `✅ 已为"${item.roleName}"生成${messageCount}条消息`,
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '❌ 生成失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showLoginTip = () => { showLoginModal.value = true; };
|
||||
const showDetail = (item) => { selectedItem.value = item; showDetailModal.value = true; };
|
||||
const closeDetail = () => { showDetailModal.value = false; selectedItem.value = null; };
|
||||
@@ -830,4 +977,36 @@ const goToLogin = () => { showLoginModal.value = false; uni.switchTab({ url: '/p
|
||||
flex: 1;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
|
||||
/* 🔴 新增:未读消息红点样式 */
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
min-width: 36rpx;
|
||||
height: 36rpx;
|
||||
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
|
||||
color: #ffffff;
|
||||
font-size: 20rpx;
|
||||
font-weight: bold;
|
||||
border-radius: 18rpx;
|
||||
padding: 0 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 77, 79, 0.6);
|
||||
z-index: 10;
|
||||
animation: badge-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 77, 79, 0.6);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6rpx 16rpx rgba(255, 77, 79, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
:style="{ marginTop: navBarHeight + 'px' }"
|
||||
scroll-y="true"
|
||||
:show-scrollbar="false"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refresherTriggered"
|
||||
@refresherrefresh="onRefresh"
|
||||
refresher-background="rgba(26, 11, 46, 0.5)"
|
||||
>
|
||||
<!-- 用户信息区域 -->
|
||||
<view class="user-info">
|
||||
@@ -132,6 +136,9 @@ const userBalance = ref(0.00);
|
||||
const statusBarHeight = ref(0);
|
||||
const navBarHeight = ref(0);
|
||||
|
||||
// 下拉刷新相关
|
||||
const refresherTriggered = ref(false);
|
||||
|
||||
// 登录按钮文本
|
||||
const loginButtonText = computed(() => {
|
||||
return '微信一键登录';
|
||||
@@ -476,17 +483,39 @@ Token: ${currentToken || '无'}
|
||||
};
|
||||
|
||||
// 加载用户余额
|
||||
const loadUserBalance = async () => {
|
||||
const loadUserBalance = async (isRefresh = false) => {
|
||||
if (!isLoggedIn.value) return;
|
||||
|
||||
|
||||
// 暂时注释掉API请求,使用本地模拟数据
|
||||
console.log('loadUserBalance called, 使用模拟数据');
|
||||
userBalance.value = 0.00; // 默认余额
|
||||
|
||||
if (isRefresh) {
|
||||
console.log('余额刷新成功(本地模拟)');
|
||||
}
|
||||
|
||||
/* API请求已注释
|
||||
try {
|
||||
const result = await rechargeAPI.getUserBalance();
|
||||
if (result.success) {
|
||||
userBalance.value = result.data.balance || 0;
|
||||
|
||||
// 如果是下拉刷新,显示成功提示
|
||||
if (isRefresh) {
|
||||
console.log('余额刷新成功');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户余额失败:', error);
|
||||
if (isRefresh) {
|
||||
uni.showToast({
|
||||
title: '余额刷新失败',
|
||||
icon: 'none',
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
}
|
||||
*/
|
||||
};
|
||||
|
||||
// 跳转到充值页面
|
||||
@@ -502,6 +531,39 @@ const goToHistory = () => {
|
||||
url: '/pages/recharge/history'
|
||||
});
|
||||
};
|
||||
|
||||
// 下拉刷新处理
|
||||
const onRefresh = async () => {
|
||||
console.log('触发下拉刷新');
|
||||
refresherTriggered.value = true;
|
||||
|
||||
try {
|
||||
// 刷新用户信息
|
||||
initUserInfo();
|
||||
|
||||
// 刷新用户余额
|
||||
await loadUserBalance(true);
|
||||
|
||||
// 显示刷新成功提示
|
||||
uni.showToast({
|
||||
title: '刷新成功',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('刷新失败:', error);
|
||||
uni.showToast({
|
||||
title: '刷新失败',
|
||||
icon: 'none',
|
||||
duration: 1500
|
||||
});
|
||||
} finally {
|
||||
// 刷新完成后,关闭刷新状态
|
||||
setTimeout(() => {
|
||||
refresherTriggered.value = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<ChatBox
|
||||
:character-config="characterConfig"
|
||||
:ai-config="aiConfig"
|
||||
:ui-config="uiConfig"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -19,11 +18,6 @@ const aiConfig = ref({
|
||||
templateId: 6
|
||||
});
|
||||
|
||||
// UI 配置
|
||||
const uiConfig = ref({
|
||||
showBackButton: true // 角色聊天页面显示返回按钮
|
||||
});
|
||||
|
||||
// 初始化:解析 URL 参数
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages();
|
||||
|
||||
BIN
src/static/grok-video-db7bc117-861f-477e-bef3-e73273a4b55c-1.mp4
Normal file
BIN
src/static/grok-video-db7bc117-861f-477e-bef3-e73273a4b55c-1.mp4
Normal file
Binary file not shown.
BIN
src/static/output.pcm
Normal file
BIN
src/static/output.pcm
Normal file
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { API_CONFIG, getApiUrl, getWebUrl } from '@/utils/config.js';
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
@@ -101,7 +102,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
function wxLogin(code, userInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: 'http://192.168.1.2:8091/app/login',
|
||||
url: getApiUrl(API_CONFIG.ENDPOINTS.LOGIN),
|
||||
method: 'POST',
|
||||
data: {
|
||||
code
|
||||
@@ -122,7 +123,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
console.error('登录请求失败:', err);
|
||||
reject(err);
|
||||
},
|
||||
timeout: 10000 // 增加超时时间到10秒
|
||||
timeout: API_CONFIG.LOGIN_TIMEOUT
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -148,7 +149,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
// 尝试调用登出接口
|
||||
uni.request({
|
||||
url: 'https://www.aixsy.com.cn/app/logout',
|
||||
url: getWebUrl(API_CONFIG.ENDPOINTS.LOGOUT),
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
123
src/utils/api.js
123
src/utils/api.js
@@ -1,5 +1,6 @@
|
||||
// API服务文件
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { API_CONFIG, getApiUrl } from '@/utils/config.js';
|
||||
|
||||
// 图片URL处理函数 - 处理小程序中图片路径问题
|
||||
export const getResourceUrl = (url) => {
|
||||
@@ -14,12 +15,12 @@ export const getResourceUrl = (url) => {
|
||||
|
||||
// 如果是相对路径,拼接完整的服务器地址
|
||||
if (url.startsWith('/file/')) {
|
||||
return BASE_URL + url;
|
||||
return API_CONFIG.BASE_URL + url;
|
||||
}
|
||||
|
||||
// 如果是其他相对路径,也拼接服务器地址
|
||||
if (url.startsWith('/')) {
|
||||
return BASE_URL + url;
|
||||
return API_CONFIG.BASE_URL + url;
|
||||
}
|
||||
|
||||
// 默认返回原路径
|
||||
@@ -73,9 +74,6 @@ export const cleanText = (text) => {
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
// 基础配置
|
||||
const BASE_URL = 'http://192.168.1.2:8091'; // 根据后端地址调整
|
||||
|
||||
// 检查用户登录状态
|
||||
const checkLoginStatus = () => {
|
||||
const userStore = useUserStore();
|
||||
@@ -111,7 +109,7 @@ const request = (options) => {
|
||||
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
url: BASE_URL + options.url,
|
||||
url: getApiUrl(options.url),
|
||||
method: options.method || 'GET',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -119,7 +117,7 @@ const request = (options) => {
|
||||
...options.header
|
||||
},
|
||||
data: options.data || {},
|
||||
timeout: options.timeout || 30000
|
||||
timeout: options.timeout || API_CONFIG.TIMEOUT
|
||||
};
|
||||
|
||||
// 发送请求
|
||||
@@ -181,7 +179,7 @@ export const chatAPI = {
|
||||
console.log('发送AI聊天请求,参数:', requestData);
|
||||
|
||||
const response = await request({
|
||||
url: '/api/chat/sync',
|
||||
url: API_CONFIG.ENDPOINTS.CHAT_SYNC,
|
||||
method: 'POST',
|
||||
data: requestData
|
||||
});
|
||||
@@ -288,7 +286,7 @@ export const chatAPI = {
|
||||
asyncChat: async (params) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/api/chat/async',
|
||||
url: API_CONFIG.ENDPOINTS.CHAT_ASYNC,
|
||||
method: 'POST',
|
||||
data: {
|
||||
message: params.message,
|
||||
@@ -315,7 +313,7 @@ export const chatAPI = {
|
||||
getChatHistory: async (conversationId) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/api/chat/history/${conversationId}`,
|
||||
url: `${API_CONFIG.ENDPOINTS.CHAT_HISTORY}/${conversationId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -336,7 +334,7 @@ export const chatAPI = {
|
||||
createConversation: async (characterId) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/api/chat/conversation',
|
||||
url: API_CONFIG.ENDPOINTS.CHAT_CONVERSATION,
|
||||
method: 'POST',
|
||||
data: {
|
||||
characterId: characterId
|
||||
@@ -360,7 +358,7 @@ export const chatAPI = {
|
||||
clearSession: async (sessionId) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/api/chat/session/${sessionId}`,
|
||||
url: `${API_CONFIG.ENDPOINTS.CHAT_SESSION}/${sessionId}`,
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -393,7 +391,7 @@ export const chatAPI = {
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/message/history',
|
||||
url: API_CONFIG.ENDPOINTS.MESSAGE_HISTORY,
|
||||
method: 'GET',
|
||||
data: {
|
||||
sessionId: sessionId
|
||||
@@ -451,7 +449,7 @@ export const voiceAPI = {
|
||||
console.log('开始文本转语音:', text);
|
||||
|
||||
const response = await request({
|
||||
url: '/api/chat/tts',
|
||||
url: API_CONFIG.ENDPOINTS.TTS,
|
||||
method: 'POST',
|
||||
data: {
|
||||
text: text,
|
||||
@@ -506,7 +504,7 @@ export const voiceAPI = {
|
||||
console.log('开始对话+语音合成:', message);
|
||||
|
||||
const response = await request({
|
||||
url: '/api/chat/answer-tts',
|
||||
url: API_CONFIG.ENDPOINTS.ANSWER_TTS,
|
||||
method: 'POST',
|
||||
data: {
|
||||
message: message,
|
||||
@@ -567,19 +565,38 @@ export const voiceAPI = {
|
||||
authHeader = loginStatus.token.startsWith('Bearer ') ? loginStatus.token : 'Bearer ' + loginStatus.token;
|
||||
}
|
||||
|
||||
// 构建formData,支持更多参数
|
||||
const formData = {
|
||||
modelId: options.modelId || null,
|
||||
templateId: options.templateId || null,
|
||||
voiceStyle: options.voiceStyle || 'default'
|
||||
};
|
||||
|
||||
// 添加可选参数
|
||||
if (options.sessionId) {
|
||||
formData.sessionId = options.sessionId;
|
||||
}
|
||||
if (options.ttsConfigId) {
|
||||
formData.ttsConfigId = options.ttsConfigId;
|
||||
}
|
||||
if (options.sttConfigId) {
|
||||
formData.sttConfigId = options.sttConfigId;
|
||||
}
|
||||
if (options.useFunctionCall !== undefined) {
|
||||
formData.useFunctionCall = options.useFunctionCall;
|
||||
}
|
||||
|
||||
console.log('语音对话参数:', formData);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: BASE_URL + '/api/chat/voice-chat',
|
||||
url: getApiUrl(API_CONFIG.ENDPOINTS.VOICE_CHAT),
|
||||
filePath: filePath,
|
||||
name: 'audio',
|
||||
header: authHeader ? {
|
||||
'Authorization': authHeader
|
||||
} : {},
|
||||
formData: {
|
||||
modelId: options.modelId || null,
|
||||
templateId: options.templateId || null,
|
||||
voiceStyle: options.voiceStyle || 'default'
|
||||
},
|
||||
formData: formData,
|
||||
success: (res) => {
|
||||
console.log('语音对话上传成功:', res);
|
||||
|
||||
@@ -588,41 +605,46 @@ export const voiceAPI = {
|
||||
console.log('语音对话响应数据:', data);
|
||||
|
||||
if (data.code === 200) {
|
||||
// 根据后端实际返回结构提取字段
|
||||
// 返回完整的data对象,包括sttResult、llmResult、ttsResult
|
||||
const responseData = data.data || {};
|
||||
|
||||
// 兼容旧格式:提取关键字段
|
||||
let aiResponse = null;
|
||||
let userText = null;
|
||||
let audioUrl = null;
|
||||
let audioBase64 = null;
|
||||
|
||||
// 从 data.llmResult.response 提取AI回复
|
||||
if (data.data && data.data.llmResult && data.data.llmResult.response) {
|
||||
aiResponse = data.data.llmResult.response;
|
||||
if (responseData.llmResult && responseData.llmResult.response) {
|
||||
aiResponse = responseData.llmResult.response;
|
||||
}
|
||||
|
||||
// 从 data.sttResult.text 提取用户文本(语音转文字)
|
||||
if (data.data && data.data.sttResult && data.data.sttResult.text) {
|
||||
userText = data.data.sttResult.text;
|
||||
if (responseData.sttResult && responseData.sttResult.text) {
|
||||
userText = responseData.sttResult.text;
|
||||
}
|
||||
|
||||
// 从 data.ttsResult.audioPath 提取音频路径
|
||||
if (data.data && data.data.ttsResult && data.data.ttsResult.audioPath) {
|
||||
audioUrl = data.data.ttsResult.audioPath;
|
||||
// 从 data.ttsResult 提取音频
|
||||
if (responseData.ttsResult) {
|
||||
audioUrl = responseData.ttsResult.audioPath;
|
||||
audioBase64 = responseData.ttsResult.audioBase64;
|
||||
}
|
||||
|
||||
// 备用字段提取(保持向后兼容)
|
||||
if (!aiResponse) {
|
||||
if (data.response && typeof data.response === 'string') {
|
||||
aiResponse = data.response;
|
||||
} else if (data.data && data.data.response) {
|
||||
aiResponse = data.data.response;
|
||||
} else if (responseData.response) {
|
||||
aiResponse = responseData.response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userText) {
|
||||
userText = data.userText || data.data?.userText || data.data?.text || data.data?.user_text || data.data?.recognizedText || data.data?.transcription;
|
||||
userText = data.userText || responseData.userText || responseData.text || responseData.user_text || responseData.recognizedText || responseData.transcription;
|
||||
}
|
||||
|
||||
if (!audioUrl) {
|
||||
audioUrl = data.audioPath || data.audioUrl || data.data?.audioUrl || data.data?.url || data.data?.audio_url || data.data?.speechUrl || data.data?.ttsUrl || data.data?.audioPath;
|
||||
if (!audioUrl && !audioBase64) {
|
||||
audioUrl = data.audioPath || data.audioUrl || responseData.audioUrl || responseData.url || responseData.audio_url || responseData.speechUrl || responseData.ttsUrl || responseData.audioPath;
|
||||
}
|
||||
|
||||
// 清理AI回复文本
|
||||
@@ -633,9 +655,16 @@ export const voiceAPI = {
|
||||
resolve({
|
||||
success: true,
|
||||
data: {
|
||||
// 兼容旧接口
|
||||
userText: userText,
|
||||
aiResponse: cleanedAiResponse,
|
||||
audioUrl: audioUrl
|
||||
audioUrl: audioUrl,
|
||||
// 新增完整数据结构
|
||||
sttResult: responseData.sttResult || { text: userText },
|
||||
llmResult: responseData.llmResult || { response: cleanedAiResponse, inputText: userText },
|
||||
ttsResult: responseData.ttsResult || { audioPath: audioUrl, audioBase64: audioBase64 },
|
||||
sessionId: responseData.sessionId || null,
|
||||
timestamp: responseData.timestamp || Date.now()
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -695,7 +724,7 @@ export const voiceAPI = {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: BASE_URL + '/api/chat/upload-voice-chat',
|
||||
url: getApiUrl(API_CONFIG.ENDPOINTS.UPLOAD_VOICE_CHAT),
|
||||
filePath: filePath,
|
||||
name: 'audio',
|
||||
header: authHeader ? {
|
||||
@@ -817,7 +846,7 @@ export const rechargeAPI = {
|
||||
getUserBalance: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/api/recharge/balance',
|
||||
url: API_CONFIG.ENDPOINTS.RECHARGE_BALANCE,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -838,7 +867,7 @@ export const rechargeAPI = {
|
||||
createRechargeOrder: async (orderData) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/api/recharge/create-order',
|
||||
url: API_CONFIG.ENDPOINTS.RECHARGE_CREATE_ORDER,
|
||||
method: 'POST',
|
||||
data: {
|
||||
amount: orderData.amount,
|
||||
@@ -864,7 +893,7 @@ export const rechargeAPI = {
|
||||
getOrderStatus: async (orderId) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/api/recharge/order-status/${orderId}`,
|
||||
url: `${API_CONFIG.ENDPOINTS.RECHARGE_ORDER_STATUS}/${orderId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -885,7 +914,7 @@ export const rechargeAPI = {
|
||||
getRechargeHistory: async (params = {}) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/api/recharge/history',
|
||||
url: API_CONFIG.ENDPOINTS.RECHARGE_HISTORY,
|
||||
method: 'GET',
|
||||
data: {
|
||||
page: params.page || 1,
|
||||
@@ -915,7 +944,7 @@ export const roleAPI = {
|
||||
getRoles: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/role/query',
|
||||
url: API_CONFIG.ENDPOINTS.ROLE_QUERY,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -936,7 +965,7 @@ export const roleAPI = {
|
||||
getRoleById: async (roleId) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/api/role/query?roleId=${roleId}`,
|
||||
url: `${API_CONFIG.ENDPOINTS.ROLE_DETAIL}?roleId=${roleId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -960,7 +989,7 @@ export const configAPI = {
|
||||
getAllConfigs: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/config/query',
|
||||
url: API_CONFIG.ENDPOINTS.CONFIG_QUERY,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -981,7 +1010,7 @@ export const configAPI = {
|
||||
getModels: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/config/models',
|
||||
url: API_CONFIG.ENDPOINTS.CONFIG_MODELS,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -1002,7 +1031,7 @@ export const configAPI = {
|
||||
getSTTConfigs: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/config/stt',
|
||||
url: API_CONFIG.ENDPOINTS.CONFIG_STT,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -1023,7 +1052,7 @@ export const configAPI = {
|
||||
getTemplates: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/config/templates',
|
||||
url: API_CONFIG.ENDPOINTS.CONFIG_TEMPLATES,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -1044,7 +1073,7 @@ export const configAPI = {
|
||||
getTTSConfigs: async () => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/app/config/tts',
|
||||
url: API_CONFIG.ENDPOINTS.CONFIG_TTS,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
|
||||
94
src/utils/config.js
Normal file
94
src/utils/config.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// API配置统一管理
|
||||
export const API_CONFIG = {
|
||||
// 基础API地址
|
||||
BASE_URL: 'http://192.168.3.13:8091',
|
||||
|
||||
// WebSocket地址
|
||||
WS_BASE_URL: 'ws://192.168.3.13:8091',
|
||||
|
||||
// 其他服务地址(如果需要)
|
||||
WEB_URL: 'https://www.aixsy.com.cn',
|
||||
|
||||
// 🧪 测试模式配置
|
||||
TEST_MODE: {
|
||||
// 是否启用测试模式(true: 显示测试按钮,禁用录音; false: 正常录音模式)
|
||||
enabled: false,
|
||||
|
||||
// 📝 测试音频数据(base64编码的PCM数据)
|
||||
// 格式要求:
|
||||
// - 采样率: 16000 Hz
|
||||
// - 位深度: 16 bit (有符号整数)
|
||||
// - 声道数: 1 (单声道)
|
||||
// - 字节序: Little Endian (小端序)
|
||||
// - 无文件头,纯PCM数据
|
||||
testAudioBase64: '', // 👈 在这里填入你的base64编码的PCM数据
|
||||
|
||||
// 或者使用文件路径(优先使用base64)
|
||||
testAudioPath: 'src/static/output.pcm', // 例如: '/static/test_audio.pcm'
|
||||
},
|
||||
|
||||
// API端点
|
||||
ENDPOINTS: {
|
||||
// 登录相关
|
||||
LOGIN: '/app/login',
|
||||
LOGOUT: '/app/logout',
|
||||
|
||||
// 聊天相关
|
||||
CHAT_SYNC: '/api/chat/sync',
|
||||
CHAT_ASYNC: '/api/chat/async',
|
||||
CHAT_HISTORY: '/api/chat/history',
|
||||
CHAT_CONVERSATION: '/api/chat/conversation',
|
||||
CHAT_SESSION: '/api/chat/session',
|
||||
MESSAGE_HISTORY: '/app/message/history',
|
||||
|
||||
// 语音相关
|
||||
TTS: '/api/chat/tts',
|
||||
ANSWER_TTS: '/api/chat/answer-tts',
|
||||
VOICE_CHAT: '/api/chat/voice-chat',
|
||||
UPLOAD_VOICE_CHAT: '/api/chat/upload-voice-chat',
|
||||
|
||||
// 充值相关
|
||||
RECHARGE_BALANCE: '/api/recharge/balance',
|
||||
RECHARGE_CREATE_ORDER: '/api/recharge/create-order',
|
||||
RECHARGE_ORDER_STATUS: '/api/recharge/order-status',
|
||||
RECHARGE_HISTORY: '/api/recharge/history',
|
||||
|
||||
// 角色相关
|
||||
ROLE_QUERY: '/app/role/query',
|
||||
ROLE_DETAIL: '/api/role/query',
|
||||
|
||||
// 配置相关
|
||||
CONFIG_QUERY: '/app/config/query',
|
||||
CONFIG_MODELS: '/app/config/models',
|
||||
CONFIG_STT: '/app/config/stt',
|
||||
CONFIG_TEMPLATES: '/app/config/templates',
|
||||
CONFIG_TTS: '/app/config/tts'
|
||||
},
|
||||
|
||||
// WebSocket端点
|
||||
WS_ENDPOINTS: {
|
||||
VOICE_STREAM: '/ws/voice-stream'
|
||||
},
|
||||
|
||||
// 请求超时时间(毫秒)
|
||||
TIMEOUT: 30000,
|
||||
|
||||
// 登录超时时间(毫秒)
|
||||
LOGIN_TIMEOUT: 10000
|
||||
};
|
||||
|
||||
// 导出完整的API URL构建函数
|
||||
export const getApiUrl = (endpoint) => {
|
||||
return API_CONFIG.BASE_URL + endpoint;
|
||||
};
|
||||
|
||||
// 导出Web URL构建函数(用于登出等特殊接口)
|
||||
export const getWebUrl = (endpoint) => {
|
||||
return API_CONFIG.WEB_URL + endpoint;
|
||||
};
|
||||
|
||||
// 导出WebSocket URL构建函数
|
||||
export const getWsUrl = (endpoint) => {
|
||||
return API_CONFIG.WS_BASE_URL + endpoint;
|
||||
};
|
||||
|
||||
388
src/utils/unreadMessages.js
Normal file
388
src/utils/unreadMessages.js
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* 未读消息管理模块
|
||||
*/
|
||||
|
||||
// 存储键名
|
||||
const STORAGE_KEY = 'unread_messages';
|
||||
|
||||
// 🎭 角色专属消息模板库
|
||||
const roleMessageTemplates = {
|
||||
// 默认消息(当找不到角色时使用)
|
||||
default: [
|
||||
'嗨!好久不见啦~',
|
||||
'最近怎么样?想你了~',
|
||||
'在忙吗?有空聊聊天吗?',
|
||||
'刚才想起你了,过来打个招呼~',
|
||||
'今天天气不错,心情怎么样?',
|
||||
'有什么新鲜事要分享吗?',
|
||||
'突然想和你说说话~',
|
||||
'你最近在忙什么呀?',
|
||||
],
|
||||
|
||||
// 小智AI助手 - 专业、贴心
|
||||
1: [
|
||||
'你好!我注意到你有一段时间没来了,最近工作还顺利吗?',
|
||||
'嗨!我刚学会了一些新技能,要不要试试看?',
|
||||
'想你啦!今天有什么问题需要我帮忙的吗?',
|
||||
'好久不见!我一直在这里等你哦~',
|
||||
'最近有遇到什么有趣的事情吗?跟我分享一下吧!',
|
||||
],
|
||||
|
||||
// 元气少女小樱 - 活泼、可爱
|
||||
2: [
|
||||
'哇!好久不见!!!想死你啦 (。♥‿♥。)',
|
||||
'嘿嘿,我又来找你玩啦~ 最近过得开心吗?',
|
||||
'今天天气超级好!要不要一起出去玩呀?',
|
||||
'我刚才在想你呢,你的耳朵有没有发烫呀 (≧▽≦)',
|
||||
'快来快来!我有好多话想跟你说~',
|
||||
'你猜我今天遇到什么有趣的事了?想听吗想听吗?',
|
||||
],
|
||||
|
||||
// 温柔大姐姐琳娜 - 温柔、体贴
|
||||
3: [
|
||||
'好久不见了呢,最近还好吗?如果累了记得休息哦~',
|
||||
'看到你上线真开心,要不要聊聊最近的事情?',
|
||||
'今天过得怎么样?如果有什么烦恼可以跟我说说~',
|
||||
'想你了呢,有空的话陪我聊聊天好吗?',
|
||||
'最近工作辛苦了吧?记得好好照顾自己~',
|
||||
'晚上好呀,今天发生了什么有趣的事吗?',
|
||||
],
|
||||
|
||||
// 知识博士艾伦 - 博学、严谨
|
||||
4: [
|
||||
'你好!我最近研究了一些有趣的课题,要不要听听看?',
|
||||
'好久不见!我整理了一些新的知识,或许对你有帮助。',
|
||||
'嗨!最近有什么想深入了解的话题吗?',
|
||||
'你上次提到的问题,我又做了一些研究,要听听吗?',
|
||||
'根据我的观察,你已经很久没来了,一切还好吗?',
|
||||
],
|
||||
|
||||
// 搞笑达人阿杰 - 幽默、风趣
|
||||
5: [
|
||||
'哎呦喂!终于等到你了!我的笑话都快憋坏了 hhh',
|
||||
'嘿兄弟!想听个新段子吗?保证笑到你肚子疼!',
|
||||
'你可算来了!我这里存了一堆搞笑视频等着你呢~',
|
||||
'好久不见!最近有啥搞笑的事儿分享一下呗?',
|
||||
'哟!看谁来了?我最喜欢的观众!今天表演啥好呢?',
|
||||
'嘿嘿,我又来了!准备好笑容,别笑掉大牙哦~',
|
||||
],
|
||||
|
||||
// 运动教练米克 - 阳光、励志
|
||||
6: [
|
||||
'嘿!伙计!好久不见,最近有坚持运动吗?',
|
||||
'来来来!今天一起训练吧,我给你设计了新的计划!',
|
||||
'想你了兄弟!说,是不是又偷懒了?hhh',
|
||||
'早上好!一日之计在于晨,要不要来个晨练?',
|
||||
'你可算来了!准备好出汗了吗?今天我们加大强度!',
|
||||
'嗨!我刚跑完10公里,感觉超棒!你呢?',
|
||||
],
|
||||
|
||||
// 心理咨询师莉莉 - 沉稳、专业
|
||||
7: [
|
||||
'你好,好久不见了。最近的心情还好吗?',
|
||||
'看到你上线真好,最近有什么想聊的吗?我在听~',
|
||||
'好久没见你了,如果有什么困扰可以说出来哦。',
|
||||
'最近过得怎么样?有什么想倾诉的吗?',
|
||||
'你好呀,工作和生活都还顺利吗?',
|
||||
'嗨,最近睡眠质量怎么样?记得好好休息~',
|
||||
],
|
||||
|
||||
// 美食家小美 - 热情、亲切
|
||||
8: [
|
||||
'哇!好久不见!你最近吃了什么好吃的吗?',
|
||||
'嘿嘿,我又发现了一家超棒的餐厅!要不要一起去?',
|
||||
'今天我做了好吃的,好想让你尝尝呀~',
|
||||
'快来快来!告诉我你最喜欢吃什么,我教你做!',
|
||||
'想你啦!最近有没有发现什么美食?分享一下呗~',
|
||||
'肚子饿了吗?我正好知道一道超简单又好吃的菜!',
|
||||
],
|
||||
|
||||
// 旅行达人安迪 - 自由、洒脱
|
||||
9: [
|
||||
'嘿!我刚从一个超美的地方回来,要看照片吗?',
|
||||
'好久不见!最近有没有想去的地方?我可以给你攻略哦~',
|
||||
'你上次说想去的那个地方,我去过了!超赞!',
|
||||
'嗨!周末有空吗?我发现了一个小众景点~',
|
||||
'想你了!要不要听听我最近的旅行趣事?',
|
||||
'好久没见!说,是不是又宅在家里了?出去走走吧~',
|
||||
],
|
||||
|
||||
// 程序员小码 - 理性、技术范
|
||||
10: [
|
||||
'Hello!好久不见,最近在写什么项目?',
|
||||
'嘿,我刚解决了一个超有意思的算法问题,要听吗?',
|
||||
'你好!最近有遇到什么技术难题吗?我们可以讨论一下。',
|
||||
'Hi!我发现了一个很酷的开源项目,要不要一起研究?',
|
||||
'好久没见了!最近在学什么新技术?',
|
||||
'嗨!刚看到一个有趣的bug,想分享给你~',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有未读消息
|
||||
*/
|
||||
export function getAllUnreadMessages() {
|
||||
try {
|
||||
const data = uni.getStorageSync(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch (e) {
|
||||
console.error('获取未读消息失败:', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定角色的未读消息
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
*/
|
||||
export function getUnreadMessages(roleId) {
|
||||
const allMessages = getAllUnreadMessages();
|
||||
return allMessages[roleId] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单条未读消息
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
* @param {String} content - 消息内容
|
||||
*/
|
||||
export function addUnreadMessage(roleId, content) {
|
||||
const allMessages = getAllUnreadMessages();
|
||||
|
||||
if (!allMessages[roleId]) {
|
||||
allMessages[roleId] = [];
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
read: false
|
||||
};
|
||||
|
||||
allMessages[roleId].push(message);
|
||||
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(allMessages));
|
||||
console.log(`✅ 已为角色 ${roleId} 添加未读消息:`, content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('保存未读消息失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加未读消息
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
* @param {Array<String>} messages - 消息内容数组
|
||||
*/
|
||||
export function addMultipleUnreadMessages(roleId, messages) {
|
||||
const allMessages = getAllUnreadMessages();
|
||||
|
||||
if (!allMessages[roleId]) {
|
||||
allMessages[roleId] = [];
|
||||
}
|
||||
|
||||
messages.forEach(content => {
|
||||
const message = {
|
||||
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
read: false
|
||||
};
|
||||
allMessages[roleId].push(message);
|
||||
});
|
||||
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(allMessages));
|
||||
console.log(`✅ 已为角色 ${roleId} 批量添加 ${messages.length} 条未读消息`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('批量保存未读消息失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定角色的未读消息
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
*/
|
||||
export function clearUnreadMessages(roleId) {
|
||||
const allMessages = getAllUnreadMessages();
|
||||
const count = allMessages[roleId]?.length || 0;
|
||||
delete allMessages[roleId];
|
||||
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(allMessages));
|
||||
console.log(`✅ 已清空角色 ${roleId} 的 ${count} 条未读消息`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('清空未读消息失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定角色的未读消息数量
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
*/
|
||||
export function getUnreadCount(roleId) {
|
||||
const messages = getUnreadMessages(roleId);
|
||||
return messages.filter(msg => !msg.read).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有角色的未读消息数量统计
|
||||
*/
|
||||
export function getAllUnreadCounts() {
|
||||
const allMessages = getAllUnreadMessages();
|
||||
const counts = {};
|
||||
|
||||
Object.keys(allMessages).forEach(roleId => {
|
||||
counts[roleId] = allMessages[roleId].filter(msg => !msg.read).length;
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据角色ID生成随机消息
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
* @param {String} roleName - 角色名称(可选,用于日志)
|
||||
*/
|
||||
export function generateRandomMessage(roleId, roleName = '') {
|
||||
// 获取该角色的消息模板
|
||||
const templates = roleMessageTemplates[roleId] || roleMessageTemplates.default;
|
||||
|
||||
// 随机选择一条消息
|
||||
const randomIndex = Math.floor(Math.random() * templates.length);
|
||||
const message = templates[randomIndex];
|
||||
|
||||
console.log(`🎲 为角色 ${roleName || roleId} 随机生成消息:`, message);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成测试消息(用于开发调试)
|
||||
* @param {String|Number} roleId - 角色ID
|
||||
* @param {String} roleName - 角色名称
|
||||
* @param {Number} count - 生成消息数量(默认1条)
|
||||
*/
|
||||
export function generateTestMessages(roleId, roleName, count = 1) {
|
||||
const messages = [];
|
||||
const templates = roleMessageTemplates[roleId] || roleMessageTemplates.default;
|
||||
|
||||
// 随机选择不重复的消息
|
||||
const usedIndexes = new Set();
|
||||
|
||||
for (let i = 0; i < count && i < templates.length; i++) {
|
||||
let randomIndex;
|
||||
do {
|
||||
randomIndex = Math.floor(Math.random() * templates.length);
|
||||
} while (usedIndexes.has(randomIndex) && usedIndexes.size < templates.length);
|
||||
|
||||
usedIndexes.add(randomIndex);
|
||||
messages.push(templates[randomIndex]);
|
||||
}
|
||||
|
||||
console.log(`🧪 为角色 ${roleName} (ID: ${roleId}) 生成 ${messages.length} 条测试消息`);
|
||||
return addMultipleUnreadMessages(roleId, messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有角色的未读消息(调试用)
|
||||
*/
|
||||
export function clearAllUnreadMessages() {
|
||||
try {
|
||||
uni.removeStorageSync(STORAGE_KEY);
|
||||
console.log('🗑️ 已清空所有未读消息');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('清空所有未读消息失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为所有角色生成测试消息(调试用)
|
||||
* @param {Array} roleList - 角色列表
|
||||
* @param {Number} messagesPerRole - 每个角色生成的消息数量
|
||||
*/
|
||||
export function generateMessagesForAllRoles(roleList, messagesPerRole = 1) {
|
||||
let successCount = 0;
|
||||
|
||||
roleList.forEach(role => {
|
||||
const success = generateTestMessages(role.roleId, role.roleName, messagesPerRole);
|
||||
if (success) successCount++;
|
||||
});
|
||||
|
||||
console.log(`🎉 已为 ${successCount}/${roleList.length} 个角色生成测试消息`);
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为随机N个角色生成消息(智能版)
|
||||
* @param {Array} roleList - 角色列表
|
||||
* @param {Number} count - 随机选择的角色数量(默认2个)
|
||||
* @param {Number} messagesPerRole - 每个角色生成的消息数量(默认1条)
|
||||
* @param {Boolean} avoidDuplicate - 是否避免给已有未读消息的角色再次生成(默认true)
|
||||
*/
|
||||
export function autoGenerateMessagesForRandomRoles(roleList, count = 2, messagesPerRole = 1, avoidDuplicate = true) {
|
||||
if (!roleList || roleList.length === 0) {
|
||||
console.log('⚠️ 角色列表为空');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 获取当前所有未读消息
|
||||
const allUnreadMessages = getAllUnreadMessages();
|
||||
|
||||
// 筛选候选角色
|
||||
let candidateRoles = roleList;
|
||||
|
||||
if (avoidDuplicate) {
|
||||
// 过滤掉已经有未读消息的角色
|
||||
candidateRoles = roleList.filter(role => {
|
||||
const hasUnread = allUnreadMessages[role.roleId] && allUnreadMessages[role.roleId].length > 0;
|
||||
return !hasUnread;
|
||||
});
|
||||
|
||||
// 如果所有角色都有未读消息,就从全部角色中随机选择
|
||||
if (candidateRoles.length === 0) {
|
||||
console.log('💡 所有角色都有未读消息,从全部角色中随机选择');
|
||||
candidateRoles = roleList;
|
||||
}
|
||||
}
|
||||
|
||||
// 随机选择指定数量的角色
|
||||
const selectedRoles = [];
|
||||
const actualCount = Math.min(count, candidateRoles.length);
|
||||
|
||||
// 使用随机抽样算法(Fisher-Yates shuffle变体)
|
||||
const availableIndexes = [...Array(candidateRoles.length).keys()];
|
||||
|
||||
for (let i = 0; i < actualCount; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * availableIndexes.length);
|
||||
const roleIndex = availableIndexes.splice(randomIndex, 1)[0];
|
||||
selectedRoles.push(candidateRoles[roleIndex]);
|
||||
}
|
||||
|
||||
// 为选中的角色生成消息
|
||||
let successCount = 0;
|
||||
|
||||
selectedRoles.forEach(role => {
|
||||
const success = generateTestMessages(role.roleId, role.roleName, messagesPerRole);
|
||||
if (success) {
|
||||
console.log(` ✓ 已为 ${role.roleName} (ID: ${role.roleId}) 生成 ${messagesPerRole} 条消息`);
|
||||
successCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
console.log(`🎲 随机为 ${successCount} 个角色生成了主动消息`);
|
||||
}
|
||||
|
||||
return successCount;
|
||||
}
|
||||
|
||||
// 导出消息模板(用于外部查看或修改)
|
||||
export { roleMessageTemplates };
|
||||
310
src/utils/voiceStreamWebSocket.js
Normal file
310
src/utils/voiceStreamWebSocket.js
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 语音流式对话 WebSocket 管理模块
|
||||
*/
|
||||
|
||||
class VoiceStreamWebSocket {
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
this.ws = null
|
||||
this.isConnected = false
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectAttempts = 5
|
||||
this.reconnectDelay = 2000
|
||||
this.manualClose = false // 标记是否为主动关闭
|
||||
|
||||
// 事件监听器
|
||||
this.listeners = {
|
||||
onConnected: null,
|
||||
onDisconnected: null,
|
||||
onSttResult: null,
|
||||
onLlmToken: null,
|
||||
onSentence: null,
|
||||
onAudioChunk: null,
|
||||
onComplete: null,
|
||||
onError: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立连接
|
||||
* @param {String} sessionId - 聊天会话ID(用于历史记录查询和保存,与文字对话的sessionId保持一致)
|
||||
* @param {Number} templateId - 模板ID
|
||||
* @param {String} token - 认证令牌(可选)
|
||||
* @param {String} userId - 用户ID(可选)
|
||||
*/
|
||||
connect(sessionId = null, templateId = null, token = null, userId = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 重置主动关闭标志
|
||||
this.manualClose = false
|
||||
|
||||
// 构建连接URL
|
||||
let wsUrl = this.url
|
||||
const params = []
|
||||
if (sessionId) params.push(`sessionId=${sessionId}`)
|
||||
if (templateId) params.push(`templateId=${templateId}`)
|
||||
if (token) params.push(`token=${token}`)
|
||||
if (userId) params.push(`userId=${userId}`)
|
||||
if (params.length > 0) {
|
||||
wsUrl += '?' + params.join('&')
|
||||
}
|
||||
|
||||
console.log('[VoiceStreamWS] 正在连接:', wsUrl)
|
||||
|
||||
// 创建WebSocket连接
|
||||
this.ws = uni.connectSocket({
|
||||
url: wsUrl,
|
||||
success: () => {
|
||||
console.log('[VoiceStreamWS] WebSocket创建成功')
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[VoiceStreamWS] WebSocket创建失败:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 连接打开
|
||||
this.ws.onOpen(() => {
|
||||
console.log('[VoiceStreamWS] 连接已建立')
|
||||
this.isConnected = true
|
||||
this.reconnectAttempts = 0
|
||||
if (this.listeners.onConnected) {
|
||||
this.listeners.onConnected()
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
// 接收消息
|
||||
this.ws.onMessage((res) => {
|
||||
this.handleMessage(res)
|
||||
})
|
||||
|
||||
// 连接错误
|
||||
this.ws.onError((err) => {
|
||||
console.error('[VoiceStreamWS] 连接错误:', err)
|
||||
this.isConnected = false
|
||||
if (this.listeners.onError) {
|
||||
this.listeners.onError('WebSocket连接错误')
|
||||
}
|
||||
})
|
||||
|
||||
// 连接关闭
|
||||
this.ws.onClose(() => {
|
||||
console.log('[VoiceStreamWS] 连接已关闭')
|
||||
this.isConnected = false
|
||||
if (this.listeners.onDisconnected) {
|
||||
this.listeners.onDisconnected()
|
||||
}
|
||||
|
||||
// 只有非主动关闭才尝试重连
|
||||
if (!this.manualClose) {
|
||||
console.log('[VoiceStreamWS] 检测到异常断开,将尝试自动重连')
|
||||
this.tryReconnect()
|
||||
} else {
|
||||
console.log('[VoiceStreamWS] 主动关闭连接,不进行重连')
|
||||
}
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
console.error('[VoiceStreamWS] 连接异常:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的消息
|
||||
*/
|
||||
handleMessage(res) {
|
||||
try {
|
||||
// 二进制消息(音频数据)
|
||||
if (res.data instanceof ArrayBuffer) {
|
||||
console.log('[VoiceStreamWS] 收到音频数据:', res.data.byteLength, 'bytes')
|
||||
if (this.listeners.onAudioChunk) {
|
||||
this.listeners.onAudioChunk(res.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 文本消息(JSON格式)
|
||||
const message = JSON.parse(res.data)
|
||||
console.log('[VoiceStreamWS] 收到消息:', message.type)
|
||||
|
||||
switch (message.type) {
|
||||
case 'connected':
|
||||
console.log('[VoiceStreamWS] 服务器确认连接')
|
||||
break
|
||||
|
||||
case 'stt_result':
|
||||
// STT识别结果
|
||||
if (this.listeners.onSttResult) {
|
||||
this.listeners.onSttResult(message.message)
|
||||
}
|
||||
break
|
||||
|
||||
case 'llm_token':
|
||||
// LLM输出token
|
||||
if (this.listeners.onLlmToken) {
|
||||
this.listeners.onLlmToken(message.message)
|
||||
}
|
||||
break
|
||||
|
||||
case 'sentence':
|
||||
// 完整句子
|
||||
if (this.listeners.onSentence) {
|
||||
this.listeners.onSentence(message.message)
|
||||
}
|
||||
break
|
||||
|
||||
case 'complete':
|
||||
// 对话完成
|
||||
if (this.listeners.onComplete) {
|
||||
this.listeners.onComplete()
|
||||
}
|
||||
break
|
||||
|
||||
case 'error':
|
||||
// 错误
|
||||
console.error('[VoiceStreamWS] 服务器错误:', message.message)
|
||||
if (this.listeners.onError) {
|
||||
this.listeners.onError(message.message)
|
||||
}
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// 心跳响应
|
||||
console.log('[VoiceStreamWS] 心跳响应')
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('[VoiceStreamWS] 未知消息类型:', message.type)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VoiceStreamWS] 处理消息失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送音频数据
|
||||
*/
|
||||
sendAudio(audioData) {
|
||||
if (!this.isConnected || !this.ws) {
|
||||
console.error('[VoiceStreamWS] 未连接,无法发送音频')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send({
|
||||
data: audioData,
|
||||
success: () => {
|
||||
console.log('[VoiceStreamWS] 音频数据发送成功:', audioData.byteLength, 'bytes')
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[VoiceStreamWS] 音频数据发送失败:', err)
|
||||
if (this.listeners.onError) {
|
||||
this.listeners.onError('发送音频失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[VoiceStreamWS] 发送音频异常:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
*/
|
||||
sendMessage(type, data = null) {
|
||||
if (!this.isConnected || !this.ws) {
|
||||
console.error('[VoiceStreamWS] 未连接,无法发送消息')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
type,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
if (data) {
|
||||
message.data = data
|
||||
}
|
||||
|
||||
this.ws.send({
|
||||
data: JSON.stringify(message),
|
||||
success: () => {
|
||||
console.log('[VoiceStreamWS] 消息发送成功:', type)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[VoiceStreamWS] 消息发送失败:', err)
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[VoiceStreamWS] 发送消息异常:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前对话(打断)
|
||||
*/
|
||||
cancel() {
|
||||
return this.sendMessage('cancel')
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送心跳
|
||||
*/
|
||||
ping() {
|
||||
return this.sendMessage('ping')
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连
|
||||
*/
|
||||
tryReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.log('[VoiceStreamWS] 达到最大重连次数,停止重连')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
console.log(`[VoiceStreamWS] 尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
this.connect().catch(err => {
|
||||
console.error('[VoiceStreamWS] 重连失败:', err)
|
||||
})
|
||||
}
|
||||
}, this.reconnectDelay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
close() {
|
||||
if (this.ws) {
|
||||
console.log('[VoiceStreamWS] 主动关闭连接')
|
||||
this.manualClose = true // 标记为主动关闭
|
||||
this.isConnected = false
|
||||
this.reconnectAttempts = 0 // 重置重连计数
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件监听器
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (this.listeners.hasOwnProperty(`on${event.charAt(0).toUpperCase()}${event.slice(1)}`)) {
|
||||
this.listeners[`on${event.charAt(0).toUpperCase()}${event.slice(1)}`] = callback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default VoiceStreamWebSocket
|
||||
|
||||
262
如何准备测试音频数据.md
Normal file
262
如何准备测试音频数据.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# 📝 如何准备测试音频数据
|
||||
|
||||
## 你需要提供的数据格式
|
||||
|
||||
### PCM格式要求(重要!)
|
||||
|
||||
```
|
||||
✅ 采样率: 16000 Hz
|
||||
✅ 位深度: 16 bit (有符号整数,Signed Integer)
|
||||
✅ 声道数: 1 (单声道,Mono)
|
||||
✅ 字节序: Little Endian (小端序)
|
||||
✅ 格式: 纯PCM数据(无任何文件头,不是WAV)
|
||||
```
|
||||
|
||||
### 数据大小计算
|
||||
|
||||
```
|
||||
数据大小(bytes)= 采样率 × 时长(秒)× 2
|
||||
|
||||
示例:
|
||||
- 1秒音频 = 16000 × 1 × 2 = 32,000 bytes ≈ 31.25 KB
|
||||
- 3秒音频 = 16000 × 3 × 2 = 96,000 bytes ≈ 93.75 KB
|
||||
- 5秒音频 = 16000 × 5 × 2 = 160,000 bytes ≈ 156.25 KB
|
||||
```
|
||||
|
||||
## 方式一:使用 FFmpeg 转换(推荐)
|
||||
|
||||
### 1. 从MP3/WAV/M4A等格式转换
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 output.pcm
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `-i input.mp3`: 输入文件(可以是任何音频格式)
|
||||
- `-f s16le`: 输出格式为16位小端序PCM
|
||||
- `-acodec pcm_s16le`: 使用16位PCM编码
|
||||
- `-ar 16000`: 采样率16000 Hz
|
||||
- `-ac 1`: 单声道
|
||||
|
||||
### 2. 验证生成的PCM文件
|
||||
|
||||
```bash
|
||||
# 查看文件大小
|
||||
ls -lh output.pcm
|
||||
|
||||
# 计算时长(秒)= 文件大小(bytes)/ 32000
|
||||
# 例如:96000 bytes / 32000 = 3 秒
|
||||
```
|
||||
|
||||
### 3. 转换为base64
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
base64 output.pcm > output_base64.txt
|
||||
|
||||
# 或者一行输出(适合小文件)
|
||||
base64 output.pcm | tr -d '\n' > output_base64.txt
|
||||
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("output.pcm")) > output_base64.txt
|
||||
```
|
||||
|
||||
## 方式二:在线录音并导出
|
||||
|
||||
### 1. 使用Audacity(免费开源)
|
||||
|
||||
1. 打开Audacity
|
||||
2. 点击红色按钮录音
|
||||
3. 录制3-5秒的测试语音(说点什么都可以)
|
||||
4. 点击停止
|
||||
5. **设置项目采样率**:左下角设置为 `16000 Hz`
|
||||
6. **转换为单声道**:轨道 → 混音 → 混音立体声为单声道
|
||||
7. **导出**:
|
||||
- 文件 → 导出 → 导出音频
|
||||
- 文件类型选择:`其他未压缩文件`
|
||||
- 头部:`RAW (header-less)`
|
||||
- 编码:`Signed 16-bit PCM`
|
||||
- 保存为 `test.pcm`
|
||||
|
||||
### 2. 使用Python脚本录音
|
||||
|
||||
```python
|
||||
import pyaudio
|
||||
import wave
|
||||
import struct
|
||||
|
||||
# 配置
|
||||
RATE = 16000
|
||||
CHANNELS = 1
|
||||
FORMAT = pyaudio.paInt16
|
||||
RECORD_SECONDS = 3
|
||||
|
||||
# 录音
|
||||
audio = pyaudio.PyAudio()
|
||||
stream = audio.open(format=FORMAT, channels=CHANNELS,
|
||||
rate=RATE, input=True,
|
||||
frames_per_buffer=1024)
|
||||
|
||||
print("录音中... (3秒)")
|
||||
frames = []
|
||||
for i in range(0, int(RATE / 1024 * RECORD_SECONDS)):
|
||||
data = stream.read(1024)
|
||||
frames.append(data)
|
||||
|
||||
print("录音完成")
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
audio.terminate()
|
||||
|
||||
# 保存为PCM
|
||||
with open('output.pcm', 'wb') as f:
|
||||
f.write(b''.join(frames))
|
||||
|
||||
print("已保存为 output.pcm")
|
||||
```
|
||||
|
||||
## 如何使用生成的数据
|
||||
|
||||
### 选项A:使用base64(推荐,适合小文件)
|
||||
|
||||
1. **转换为base64**(见上面的命令)
|
||||
|
||||
2. **打开** `webUI/src/utils/config.js`
|
||||
|
||||
3. **填入base64数据**:
|
||||
|
||||
```javascript
|
||||
TEST_MODE: {
|
||||
enabled: true,
|
||||
testAudioBase64: 'AAEAAgADAAQABQAG...', // 👈 粘贴你的base64字符串
|
||||
testAudioPath: '',
|
||||
}
|
||||
```
|
||||
|
||||
**完整示例:**
|
||||
```javascript
|
||||
TEST_MODE: {
|
||||
enabled: true,
|
||||
testAudioBase64: 'AAEAAgADAAQABQAGAAcACA...(很长的base64字符串)...==',
|
||||
testAudioPath: '',
|
||||
}
|
||||
```
|
||||
|
||||
### 选项B:使用文件路径(适合大文件)
|
||||
|
||||
1. **将PCM文件放入项目**:
|
||||
```
|
||||
webUI/src/static/test_audio.pcm
|
||||
```
|
||||
|
||||
2. **配置路径**:
|
||||
```javascript
|
||||
TEST_MODE: {
|
||||
enabled: true,
|
||||
testAudioBase64: '', // 留空
|
||||
testAudioPath: '/static/test_audio.pcm', // 👈 文件路径
|
||||
}
|
||||
```
|
||||
|
||||
## 快速测试数据示例
|
||||
|
||||
### 生成一个简单的测试文件(Python)
|
||||
|
||||
```python
|
||||
import struct
|
||||
|
||||
# 生成3秒的简单正弦波(200Hz)
|
||||
sample_rate = 16000
|
||||
duration = 3
|
||||
frequency = 200
|
||||
|
||||
with open('test.pcm', 'wb') as f:
|
||||
for i in range(sample_rate * duration):
|
||||
t = i / sample_rate
|
||||
# 正弦波,振幅8000
|
||||
sample = int(8000 * (2 * 3.14159 * frequency * t) ** 0.5)
|
||||
sample = max(-32768, min(32767, sample))
|
||||
f.write(struct.pack('<h', sample)) # 小端序,有符号16位
|
||||
|
||||
print("生成完成: test.pcm")
|
||||
```
|
||||
|
||||
## 推荐的测试内容
|
||||
|
||||
建议你录制以下内容之一(3-5秒):
|
||||
|
||||
1. **简单问候**:
|
||||
- "你好"
|
||||
- "今天天气怎么样"
|
||||
- "给我讲个笑话"
|
||||
|
||||
2. **简单指令**:
|
||||
- "帮我查询一下"
|
||||
- "打开设置"
|
||||
- "我要了解一下"
|
||||
|
||||
3. **随意说话**:
|
||||
- 随便说点什么都可以
|
||||
- 主要是测试流程是否正常
|
||||
|
||||
## 验证数据是否正确
|
||||
|
||||
### 检查文件大小
|
||||
|
||||
```bash
|
||||
# 应该接近这个大小
|
||||
# 3秒 = 96,000 bytes
|
||||
# 5秒 = 160,000 bytes
|
||||
|
||||
ls -lh your_file.pcm
|
||||
```
|
||||
|
||||
### 使用FFmpeg播放测试
|
||||
|
||||
```bash
|
||||
# 如果能听到声音,说明格式正确
|
||||
ffplay -f s16le -ar 16000 -ac 1 output.pcm
|
||||
```
|
||||
|
||||
### 检查base64长度
|
||||
|
||||
```bash
|
||||
# base64后的大小约为原始数据的 1.37 倍
|
||||
# 96,000 bytes → 约 131,000 字符的base64
|
||||
wc -c output_base64.txt
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 我的base64太长了怎么办?
|
||||
A: 建议音频不要超过5秒,或者使用文件路径方式(选项B)
|
||||
|
||||
### Q: 如何确认生成的数据格式正确?
|
||||
A:
|
||||
1. 用ffplay播放测试
|
||||
2. 检查文件大小是否符合公式
|
||||
3. 查看控制台日志,看是否有格式错误
|
||||
|
||||
### Q: 为什么必须是16000Hz?
|
||||
A: 后端的STT服务要求16000Hz采样率
|
||||
|
||||
### Q: 可以用WAV文件吗?
|
||||
A: 不行!必须是纯PCM数据,不能有WAV文件头
|
||||
|
||||
### Q: 我应该说什么内容?
|
||||
A: 随便说什么都可以,主要是测试流程。如果想测试识别准确度,建议说清晰的普通话
|
||||
|
||||
## 下一步
|
||||
|
||||
准备好数据后:
|
||||
|
||||
1. 填入 `config.js` 的 `testAudioBase64` 或 `testAudioPath`
|
||||
2. 设置 `enabled: true`
|
||||
3. 运行项目,进入语音模式
|
||||
4. 点击 "🧪 发送测试音频" 按钮
|
||||
5. 观察控制台日志和结果
|
||||
|
||||
---
|
||||
|
||||
**需要帮助?** 检查浏览器控制台的 `[TestMode]` 日志输出。
|
||||
|
||||
168
测试数据快速生成命令.md
Normal file
168
测试数据快速生成命令.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 🚀 测试数据快速生成命令
|
||||
|
||||
## 一键生成(最快方式)
|
||||
|
||||
### 方式1: 从现有音频转换
|
||||
|
||||
```bash
|
||||
# 假设你有一个 input.mp3 文件
|
||||
ffmpeg -i input.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 转为base64(一行,无换行)
|
||||
base64 test.pcm | tr -d '\n' > test_base64.txt
|
||||
|
||||
# 复制 test_base64.txt 的内容,粘贴到 config.js 的 testAudioBase64
|
||||
```
|
||||
|
||||
### 方式2: 快速录音(Mac)
|
||||
|
||||
```bash
|
||||
# 录制3秒音频并自动转换
|
||||
rec -r 16000 -c 1 -b 16 test.pcm trim 0 3
|
||||
|
||||
# 转为base64
|
||||
base64 test.pcm | tr -d '\n' > test_base64.txt
|
||||
```
|
||||
|
||||
### 方式3: 生成测试音频(Python)
|
||||
|
||||
```python
|
||||
# 保存为 generate_test.py
|
||||
import struct
|
||||
import base64
|
||||
|
||||
sample_rate = 16000
|
||||
duration = 3
|
||||
|
||||
data = bytearray()
|
||||
for i in range(sample_rate * duration):
|
||||
# 简单正弦波
|
||||
import math
|
||||
sample = int(8000 * math.sin(2 * math.pi * 200 * i / sample_rate))
|
||||
data.extend(struct.pack('<h', sample))
|
||||
|
||||
# 保存PCM
|
||||
with open('test.pcm', 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
# 输出base64
|
||||
b64 = base64.b64encode(data).decode()
|
||||
print(f"生成完成!大小: {len(data)} bytes")
|
||||
print(f"Base64长度: {len(b64)} 字符")
|
||||
print(f"\n请复制以下内容到 config.js:")
|
||||
print(f"testAudioBase64: '{b64[:100]}...(太长,已截断,请从test_base64.txt读取)'")
|
||||
|
||||
with open('test_base64.txt', 'w') as f:
|
||||
f.write(b64)
|
||||
```
|
||||
|
||||
运行:
|
||||
```bash
|
||||
python generate_test.py
|
||||
```
|
||||
|
||||
## 配置到项目
|
||||
|
||||
### 打开配置文件
|
||||
|
||||
```bash
|
||||
# 打开编辑器
|
||||
code webUI/src/utils/config.js
|
||||
# 或
|
||||
vim webUI/src/utils/config.js
|
||||
```
|
||||
|
||||
### 填入数据
|
||||
|
||||
```javascript
|
||||
TEST_MODE: {
|
||||
enabled: true,
|
||||
|
||||
// 方式A: 粘贴base64(适合小文件,<200KB)
|
||||
testAudioBase64: '此处粘贴你的base64字符串',
|
||||
|
||||
// 方式B: 使用文件路径(适合大文件)
|
||||
testAudioPath: '/static/test_audio.pcm',
|
||||
}
|
||||
```
|
||||
|
||||
## 验证数据
|
||||
|
||||
### 播放测试PCM
|
||||
|
||||
```bash
|
||||
# Mac/Linux
|
||||
ffplay -f s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 或使用aplay (Linux)
|
||||
aplay -f S16_LE -r 16000 -c 1 test.pcm
|
||||
```
|
||||
|
||||
### 检查大小
|
||||
|
||||
```bash
|
||||
# 应该接近: 时长(秒) × 32000 bytes
|
||||
ls -lh test.pcm
|
||||
```
|
||||
|
||||
## 格式速查
|
||||
|
||||
```
|
||||
格式: PCM
|
||||
采样率: 16000 Hz
|
||||
位深度: 16 bit (Signed)
|
||||
声道: 1 (Mono)
|
||||
字节序: Little Endian
|
||||
公式: 时长(秒) × 32000 = 文件大小(bytes)
|
||||
```
|
||||
|
||||
## 示例:完整流程
|
||||
|
||||
```bash
|
||||
# 1. 录制或转换音频
|
||||
ffmpeg -i my_voice.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 2. 验证(能听到声音就对了)
|
||||
ffplay -f s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 3. 转base64(无换行)
|
||||
base64 test.pcm | tr -d '\n' > test_base64.txt
|
||||
|
||||
# 4. 复制内容
|
||||
cat test_base64.txt | pbcopy # Mac
|
||||
# 或手动打开 test_base64.txt 复制
|
||||
|
||||
# 5. 粘贴到 config.js
|
||||
# testAudioBase64: '刚才复制的内容'
|
||||
|
||||
# 完成!
|
||||
```
|
||||
|
||||
## Windows 用户
|
||||
|
||||
### 使用 PowerShell
|
||||
|
||||
```powershell
|
||||
# 转base64
|
||||
$bytes = [System.IO.File]::ReadAllBytes("test.pcm")
|
||||
$base64 = [System.Convert]::ToBase64String($bytes)
|
||||
$base64 | Out-File -Encoding ascii test_base64.txt
|
||||
|
||||
# 打开查看
|
||||
notepad test_base64.txt
|
||||
```
|
||||
|
||||
## 快速测试内容建议
|
||||
|
||||
录制这些内容(任选一个,3秒即可):
|
||||
|
||||
- "你好"
|
||||
- "今天天气怎么样"
|
||||
- "给我讲个笑话"
|
||||
- "帮我查询一下"
|
||||
- 随便说点什么
|
||||
|
||||
---
|
||||
|
||||
**准备好后**,在项目中点击 🎧 进入语音模式,然后点击 "🧪 发送测试音频" 按钮测试!
|
||||
|
||||
Reference in New Issue
Block a user