zlwu há 1 ano atrás
pai
commit
d063b38c0e

+ 115 - 0
SSL证书配置.md

@@ -0,0 +1,115 @@
+# SSL证书配置指南
+
+## 环境说明
+
+根据您的Nginx配置:
+- **生产域名**: www.hikelly.chat
+- **服务器IP**: 101.201.60.170
+- **后端端口**: 8766(Nginx代理目标)
+- **WebSocket路径**: /ws
+
+## 快速配置
+
+### 开发环境
+
+#### 1. 生成自签名证书
+
+```bash
+cd ssl
+openssl req -x509 -newkey rsa:4096 -nodes \
+  -out cert.pem -keyout key.pem -days 365 \
+  -config openssl.cnf -extensions v3_req
+```
+
+#### 2. 直接启动(自动使用本地证书)
+
+```bash
+# 后端
+cd backend
+python main.py
+
+# 前端
+cd frontend
+npm run dev
+```
+
+### 生产环境
+
+#### 方式一:直接配置证书(不推荐)
+
+**后端配置** (`backend/.env`):
+```bash
+SSL_KEYFILE=/etc/nginx/cert/www.hikelly.chat.key
+SSL_CERTFILE=/etc/nginx/cert/www.hikelly.chat.pem
+```
+
+**前端配置** (`frontend/.env`):
+```bash
+VITE_SSL_KEYFILE=/etc/nginx/cert/www.hikelly.chat.key
+VITE_SSL_CERTFILE=/etc/nginx/cert/www.hikelly.chat.pem
+```
+
+#### 方式二:Nginx反向代理(推荐)
+
+前后端都不配置SSL,证书统一由Nginx管理:
+
+```nginx
+server {
+    listen 443 ssl;
+    server_name www.hikelly.chat;
+    
+    ssl_certificate /etc/nginx/cert/www.hikelly.chat.pem;
+    ssl_certificate_key /etc/nginx/cert/www.hikelly.chat.key;
+    
+    # 前端
+    location / {
+        root /path/to/Tolan/frontend/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+    
+    # 后端API
+    location /api/ {
+        proxy_pass http://127.0.0.1:8000;
+    }
+    
+    # WebSocket
+    location /ws {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+}
+```
+
+## 证书权限检查
+
+```bash
+# 检查证书文件
+ls -l /etc/nginx/cert/www.hikelly.chat.*
+
+# 证书应该是 644
+# 私钥应该是 600 或 400
+sudo chmod 644 /etc/nginx/cert/www.hikelly.chat.pem
+sudo chmod 600 /etc/nginx/cert/www.hikelly.chat.key
+```
+
+## 访问地址
+
+- 开发环境:
+  - 前端: https://localhost:3000
+  - 后端: https://localhost:8000
+  - WebSocket: wss://localhost:8000/ws
+
+- 生产环境:
+  - 前端: https://www.hikelly.chat
+  - 后端API: https://www.hikelly.chat/api
+  - WebSocket: wss://www.hikelly.chat/ws
+
+## 详细文档
+
+- 后端配置: `backend/SSL_CONFIG.md`
+- 前端配置: `frontend/SSL_CONFIG.md`
+- 本地证书: `ssl/README.md`
+

+ 17 - 0
backend/.env.template

@@ -0,0 +1,17 @@
+# ========== 服务器配置 ==========
+# 注意:变量名必须大写
+HOST=127.0.0.1
+PORT=8766
+
+# ========== SSL证书配置 ==========
+# 生产环境使用Nginx管理证书,留空
+SSL_KEYFILE=
+SSL_CERTFILE=
+
+# ========== API密钥(必填)==========
+DASHSCOPE_API_KEY=your_dashscope_api_key_here
+DOUBAO_API_KEY=your_doubao_api_key_here
+DOUBAO_APP_ID=your_doubao_app_id_here
+
+# ========== 其他配置 ==========
+CORS_ALLOW_ALL=true

+ 84 - 0
backend/SSL_CONFIG.md

@@ -0,0 +1,84 @@
+# SSL证书配置指南
+
+## 环境变量配置
+
+在 `backend/` 目录创建 `.env` 文件(不会提交到git):
+
+### 生产环境配置
+
+```bash
+# 使用生产证书
+SSL_KEYFILE=/etc/nginx/cert/www.hikelly.chat.key
+SSL_CERTFILE=/etc/nginx/cert/www.hikelly.chat.pem
+
+# 其他必需配置
+DASHSCOPE_API_KEY=your_api_key
+DOUBAO_API_KEY=your_api_key
+DOUBAO_APP_ID=your_app_id
+```
+
+### 开发环境配置
+
+```bash
+# 留空或不配置,使用本地 ssl/ 目录的自签名证书
+SSL_KEYFILE=
+SSL_CERTFILE=
+
+# 或者完全不设置这两个变量
+```
+
+## 证书文件权限
+
+生产环境确保后端进程有读取证书的权限:
+
+```bash
+# 检查证书文件权限
+ls -l /etc/nginx/cert/www.hikelly.chat.*
+
+# 如需调整(谨慎操作)
+sudo chmod 644 /etc/nginx/cert/www.hikelly.chat.pem
+sudo chmod 600 /etc/nginx/cert/www.hikelly.chat.key
+```
+
+## 启动服务
+
+```bash
+cd backend
+python main.py
+```
+
+启动时会显示使用的证书路径。
+
+## 优先级
+
+1. 优先使用环境变量 `SSL_KEYFILE` 和 `SSL_CERTFILE`(生产)
+2. 未配置则使用 `ssl/key.pem` 和 `ssl/cert.pem`(开发)
+3. 证书不存在则以HTTP模式启动
+
+## Nginx配置(可选)
+
+生产环境建议使用Nginx反向代理:
+
+```nginx
+server {
+    listen 443 ssl;
+    server_name www.hikelly.chat;
+    
+    ssl_certificate /etc/nginx/cert/www.hikelly.chat.pem;
+    ssl_certificate_key /etc/nginx/cert/www.hikelly.chat.key;
+    
+    location / {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+```
+
+使用Nginx时,后端可以不配置SSL,直接HTTP运行。
+

+ 4 - 4
backend/config.py

@@ -18,10 +18,10 @@ class Settings(BaseSettings):
     DEBUG: bool = False
     
     # 服务器配置
-    HOST: str = "0.0.0.0"
-    PORT: int = 8000
-    host: str = "0.0.0.0"  # 别名,兼容小写
-    port: int = 8000  # 别名,兼容小写
+    HOST: str = os.getenv("HOST", "0.0.0.0")
+    PORT: int = int(os.getenv("PORT", "8000"))
+    host: str = os.getenv("HOST", "0.0.0.0")  # 别名,兼容小写
+    port: int = int(os.getenv("PORT", "8000"))  # 别名,兼容小写
     
     # WebSocket配置
     WS_HEARTBEAT_INTERVAL: int = 30  # 心跳间隔(秒)

+ 37 - 8
backend/main.py

@@ -36,16 +36,45 @@ if __name__ == "__main__":
     import os
     from pathlib import Path
     
-    # SSL证书路径
-    ssl_dir = Path(__file__).parent.parent / "ssl"
-    ssl_keyfile = str(ssl_dir / "key.pem")
-    ssl_certfile = str(ssl_dir / "cert.pem")
+    # 打印启动配置
+    print("="*50)
+    print("Tolan 后端服务启动")
+    print("="*50)
+    print(f"主机: {settings.HOST}")
+    print(f"端口: {settings.PORT}")
+    
+    # SSL证书路径配置
+    # 优先使用环境变量配置的证书路径(生产环境)
+    ssl_keyfile = settings.SSL_KEYFILE
+    ssl_certfile = settings.SSL_CERTFILE
+    
+    # 如果环境变量未配置,使用本地开发证书
+    if not ssl_keyfile or not ssl_certfile:
+        ssl_dir = Path(__file__).parent.parent / "ssl"
+        ssl_keyfile = str(ssl_dir / "key.pem")
+        ssl_certfile = str(ssl_dir / "cert.pem")
     
     # 检查证书是否存在
-    if not os.path.exists(ssl_keyfile) or not os.path.exists(ssl_certfile):
-        print(f"警告: SSL证书不存在,请检查 {ssl_dir} 目录")
-        ssl_keyfile = None
-        ssl_certfile = None
+    if ssl_keyfile and ssl_certfile:
+        if not os.path.exists(ssl_keyfile) or not os.path.exists(ssl_certfile):
+            print(f"⚠️  SSL证书不存在")
+            print(f"  证书: {ssl_certfile}")
+            print(f"  私钥: {ssl_keyfile}")
+            print("  将以HTTP模式启动")
+            ssl_keyfile = None
+            ssl_certfile = None
+        else:
+            print(f"✅ 使用SSL证书:")
+            print(f"  证书: {ssl_certfile}")
+            print(f"  私钥: {ssl_keyfile}")
+    
+    # 显示访问地址
+    protocol = "https" if ssl_keyfile and ssl_certfile else "http"
+    ws_protocol = "wss" if ssl_keyfile and ssl_certfile else "ws"
+    print(f"\n访问地址:")
+    print(f"  HTTP: {protocol}://{settings.HOST}:{settings.PORT}")
+    print(f"  WebSocket: {ws_protocol}://{settings.HOST}:{settings.PORT}/ws")
+    print("="*50 + "\n")
     
     uvicorn.run(
         "main:app",

+ 1 - 1
backend/requirements.txt

@@ -19,6 +19,6 @@ pydantic==2.6.1
 pydantic-settings==2.2.1
 
 # 音频处理
-numpy==1.24.3
+numpy>=1.26.0
 pydub==0.25.1
 

+ 74 - 6
backend/websocket_handler.py

@@ -29,6 +29,7 @@ class WebSocketHandler:
         self.cancelled_requests: set = set()  # 已取消的请求ID
         self.recognition_sessions: Dict[str, any] = {}  # requestId -> Recognition对象
         self.recognition_results: Dict[str, str] = {}  # requestId -> 识别结果文本
+        self.recognition_sentences: Dict[str, list] = {}  # requestId -> 句子列表
         self.recognition_failed: set = set()  # STT识别失败的请求ID
         self.event_loop: Optional[asyncio.AbstractEventLoop] = None  # 保存事件循环引用
         self.request_start_times: Dict[str, float] = {}  # 请求开始时间
@@ -137,6 +138,9 @@ class WebSocketHandler:
         request_id = message.requestId
         import time
         
+        # 记录录音结束时间
+        audio_end_time = time.time()
+        
         # 统计音频流信息
         total_size = len(self.audio_buffers.get(request_id, bytearray()))
         duration_seconds = total_size / (16000 * 2)
@@ -155,6 +159,11 @@ class WebSocketHandler:
         try:
             # 1. STT: 等待完整识别结果(保证准确性)
             stt_start = time.time()
+            
+            # 等待一小段时间,让STT处理完最后的音频帧
+            print(f"[{request_id}] ⏳ 等待STT处理最后的音频帧...")
+            await asyncio.sleep(0.3)  # 300ms缓冲时间
+            
             await self.stop_streaming_stt(request_id)
             
             # 获取STT识别结果
@@ -197,6 +206,7 @@ class WebSocketHandler:
             tts_start = time.time()
             
             chunk_index = 0
+            first_audio_time = None
             async for audio_chunk in ai_service.text_to_speech_stream(response_text, emotion):
                 if request_id in self.cancelled_requests:
                     return
@@ -211,6 +221,13 @@ class WebSocketHandler:
                     chunk_index,
                     False,
                 )
+                
+                # 记录第一个音频块发送时间
+                if chunk_index == 0:
+                    first_audio_time = time.time()
+                    time_to_first_audio = first_audio_time - audio_end_time
+                    print(f"[{request_id}] 🎵 第一个音频块已发送 [耗时: {time_to_first_audio:.2f}秒 = 录音结束→第一个音频块]")
+                
                 chunk_index += 1
             
             tts_end = time.time()
@@ -220,10 +237,27 @@ class WebSocketHandler:
             # 发送TTS结束标记
             await self.send_audio_response(request_id, "", chunk_index, True)
             
+            # 记录最后一个音频块发送时间
+            last_audio_time = time.time()
+            time_to_last_audio = last_audio_time - audio_end_time
+            print(f"[{request_id}] 🎵 最后一个音频块已发送 [耗时: {time_to_last_audio:.2f}秒 = 录音结束→最后一个音频块]")
+            
             # 计算总耗时
             total_end = time.time()
             total_duration = total_end - self.request_start_times.get(request_id, stt_start)
-            print(f"[{request_id}] ⏱️  总耗时: {total_duration:.2f}秒 (STT: {stt_duration:.2f}s + LLM: {llm_duration:.2f}s + TTS: {tts_duration:.2f}s)")
+            
+            # 性能汇总日志
+            print(f"\n{'='*60}")
+            print(f"[{request_id}] 📊 性能汇总报告")
+            print(f"{'='*60}")
+            print(f"  STT 耗时:              {stt_duration:.2f}秒")
+            print(f"  LLM 耗时:              {llm_duration:.2f}秒")
+            print(f"  TTS 耗时:              {tts_duration:.2f}秒")
+            if first_audio_time:
+                print(f"  🎯 录音结束→首个音频:  {time_to_first_audio:.2f}秒 ⭐")
+                print(f"  🎯 录音结束→全部音频:  {time_to_last_audio:.2f}秒")
+            print(f"  总耗时:                {total_duration:.2f}秒")
+            print(f"{'='*60}")
             print(f"[{request_id}] ✅ 请求处理完成\n")
             
         except Exception as e:
@@ -270,18 +304,44 @@ class WebSocketHandler:
                     sentence = result.get_sentence()
                     if sentence and 'text' in sentence:
                         text = sentence['text']
-                        # 判断是否为最终结果(end_time 可能为 None)
+                        begin_time = sentence.get('begin_time', 0)
                         end_time = sentence.get('end_time')
                         is_final = end_time is not None and end_time > 0
                         status = "✅最终" if is_final else "⏳中间"
-                        print(f"[{req_id}] 💬 STT识别({status}): {text}")
-                        # 保存最新的识别结果
-                        handler.recognition_results[req_id] = text
+                        
+                        print(f"[{req_id}] 💬 STT识别({status}): {text} [时间: {begin_time}-{end_time}]")
+                        
+                        # 初始化句子列表
+                        if req_id not in handler.recognition_sentences:
+                            handler.recognition_sentences[req_id] = []
+                        
+                        sentence_list = handler.recognition_sentences[req_id]
+                        
+                        # 查找是否是已有句子的更新(根据begin_time匹配)
+                        updated = False
+                        for i, sent in enumerate(sentence_list):
+                            if abs(sent['begin_time'] - begin_time) < 100:  # 100ms容差
+                                # 更新现有句子
+                                sentence_list[i] = {'text': text, 'begin_time': begin_time, 'end_time': end_time}
+                                updated = True
+                                break
+                        
+                        if not updated:
+                            # 新句子,追加
+                            sentence_list.append({'text': text, 'begin_time': begin_time, 'end_time': end_time})
+                        
+                        # 按时间排序并合并所有句子
+                        sentence_list.sort(key=lambda x: x['begin_time'])
+                        accumulated_text = ' '.join([s['text'] for s in sentence_list])
+                        
+                        handler.recognition_results[req_id] = accumulated_text
+                        print(f"[{req_id}] 📝 累积文本: {accumulated_text}")
+                        
                         # 从回调线程安全地发送到事件循环
                         if handler.event_loop:
                             try:
                                 asyncio.run_coroutine_threadsafe(
-                                    handler.send_text_result(req_id, text, False),
+                                    handler.send_text_result(req_id, accumulated_text, False),
                                     handler.event_loop
                                 )
                             except Exception as e:
@@ -332,6 +392,10 @@ class WebSocketHandler:
                 import time
                 stop_start = time.time()
                 await asyncio.to_thread(recognition.stop)
+                
+                # 停止后再等待一小段时间,确保最后的识别结果回调被触发
+                await asyncio.sleep(0.2)  # 200ms等待最终结果
+                
                 stop_duration = (time.time() - stop_start) * 1000
                 print(f"[{request_id}] ✅ STT识别已停止 (耗时: {stop_duration:.1f}ms)")
         except Exception as e:
@@ -351,6 +415,10 @@ class WebSocketHandler:
         if request_id in self.recognition_results:
             del self.recognition_results[request_id]
         
+        # 清理句子列表
+        if request_id in self.recognition_sentences:
+            del self.recognition_sentences[request_id]
+        
         # 清理取消标记
         if request_id in self.cancelled_requests:
             self.cancelled_requests.remove(request_id)

+ 99 - 0
backend/端口配置说明.md

@@ -0,0 +1,99 @@
+# 后端端口配置说明
+
+## 问题修复
+
+已修复端口配置不生效的问题。现在环境变量可以正常读取。
+
+## .env 文件配置
+
+在 `backend/` 目录创建 `.env` 文件:
+
+### 开发环境
+```bash
+# 服务器配置(大小写敏感)
+HOST=0.0.0.0
+PORT=8000
+
+# API密钥
+DASHSCOPE_API_KEY=your_key
+DOUBAO_API_KEY=your_key
+DOUBAO_APP_ID=your_id
+```
+
+### 生产环境(Nginx代理)
+```bash
+# 监听本地8766端口(对应Nginx配置)
+HOST=127.0.0.1
+PORT=8766
+
+# 不需要配置SSL(Nginx管理)
+SSL_KEYFILE=
+SSL_CERTFILE=
+
+# API密钥
+DASHSCOPE_API_KEY=your_key
+DOUBAO_API_KEY=your_key
+DOUBAO_APP_ID=your_id
+```
+
+## 重要提示
+
+1. **变量名大小写敏感**:必须使用大写 `PORT`,不能是 `port`
+2. **.env 文件位置**:必须在 `backend/` 目录下
+3. **无需引号**:环境变量值不需要加引号
+   ```bash
+   # 正确
+   PORT=8766
+   
+   # 也可以(但不必要)
+   PORT="8766"
+   ```
+
+## 验证配置
+
+启动后端时会显示读取到的配置:
+
+```
+==================================================
+Tolan 后端服务启动
+==================================================
+主机: 127.0.0.1
+端口: 8766
+⚠️  SSL证书不存在
+  将以HTTP模式启动
+
+访问地址:
+  HTTP: http://127.0.0.1:8766
+  WebSocket: ws://127.0.0.1:8766/ws
+==================================================
+```
+
+如果显示的端口仍然是 8000,检查:
+- `.env` 文件是否在 `backend/` 目录
+- 变量名是否为大写 `PORT`
+- 重启后端服务
+
+## 配置模板
+
+复制以下内容到 `backend/.env`:
+
+```bash
+# ========== 服务器配置 ==========
+HOST=127.0.0.1
+PORT=8766
+
+# ========== SSL证书配置 ==========
+SSL_KEYFILE=
+SSL_CERTFILE=
+
+# ========== API密钥 ==========
+DASHSCOPE_API_KEY=
+DOUBAO_API_KEY=
+DOUBAO_APP_ID=
+
+# ========== 其他配置 ==========
+CORS_ALLOW_ALL=true
+```
+
+然后填入实际的API密钥即可。
+

+ 43 - 0
frontend/AUDIO_CONFIG.md

@@ -0,0 +1,43 @@
+# 移动端音频配置
+
+## 功能特性
+- ✅ 自动检测移动设备
+- ✅ 通过扬声器播放音频(非听筒)
+- ✅ 音量增强(默认 2.5 倍)
+- ✅ 动态压缩器防止失真
+
+## 音量调节
+
+```typescript
+// 在 App.tsx 初始化时调整增益
+playerRef.current?.setGain(3.0);  // 3倍音量
+
+// 参数范围: 0.5 - 5.0
+// 推荐值: 2.0 - 3.5
+```
+
+## 压缩器参数
+
+位置: `audioPlayer.ts` Line 34-39
+
+```typescript
+threshold: -24    // 阈值(dB)
+knee: 30          // 拐点
+ratio: 12         // 压缩比
+attack: 0.003     // 启动时间
+release: 0.25     // 释放时间
+```
+
+## 测试
+
+1. 移动端浏览器打开应用
+2. 首次点击录音按钮会提示"请确保设备音量已调高"
+3. 系统自动激活扬声器模式
+4. 检查声音是否从扬声器输出
+
+## 注意事项
+
+- iOS: 确保设备未开启静音模式
+- 首次使用需用户交互(点击按钮)
+- 建议测试不同音量增益值找到最佳效果
+

+ 118 - 0
frontend/SSL_CONFIG.md

@@ -0,0 +1,118 @@
+# 前端配置指南
+
+## 环境变量配置
+
+### 快速开始
+
+复制环境配置文件:
+```bash
+cd frontend
+cp .env.development .env
+```
+
+### 配置项说明
+
+#### WebSocket URL配置
+
+**生产环境** (`.env.production`):
+```bash
+# 通过Nginx域名访问
+VITE_WS_URL=wss://www.hikelly.chat/ws
+```
+
+**开发环境** (`.env.development`):
+```bash
+# 本地HTTPS访问
+VITE_WS_URL=wss://localhost:8000/ws
+
+# 或本地HTTP(不推荐,浏览器会限制麦克风)
+# VITE_WS_URL=ws://localhost:8000/ws
+```
+
+**局域网测试**:
+```bash
+# 替换为实际IP地址
+VITE_WS_URL=wss://192.168.2.112:8000/ws
+```
+
+#### SSL证书配置
+
+**开发环境**:
+```bash
+# 留空,自动使用 ../ssl/ 的自签名证书
+VITE_SSL_KEYFILE=
+VITE_SSL_CERTFILE=
+```
+
+**生产环境**:
+```bash
+# 使用Nginx管理证书,前端不需要配置
+VITE_SSL_KEYFILE=
+VITE_SSL_CERTFILE=
+```
+
+## 启动服务
+
+```bash
+cd frontend
+npm run dev
+```
+
+## 证书优先级
+
+1. 优先使用环境变量 `VITE_SSL_KEYFILE` 和 `VITE_SSL_CERTFILE`(生产)
+2. 未配置则使用 `../ssl/key.pem` 和 `../ssl/cert.pem`(开发)
+3. 证书不存在则以HTTP模式启动
+
+## 生产环境部署(推荐)
+
+生产环境建议使用Nginx托管静态文件,而非直接运行Vite dev server:
+
+### 1. 构建前端
+
+```bash
+cd frontend
+npm run build
+```
+
+### 2. Nginx配置
+
+```nginx
+server {
+    listen 443 ssl;
+    server_name www.hikelly.chat;
+    
+    ssl_certificate /etc/nginx/cert/www.hikelly.chat.pem;
+    ssl_certificate_key /etc/nginx/cert/www.hikelly.chat.key;
+    
+    # 前端静态文件
+    location / {
+        root /path/to/Tolan/frontend/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+    
+    # 后端API代理
+    location /api/ {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+    
+    # WebSocket代理
+    location /ws {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+}
+```
+
+使用Nginx时,前后端都不需要配置SSL,证书统一由Nginx管理。
+

+ 20 - 5
frontend/src/App.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { message as antMessage } from 'antd';
 import { v4 as uuidv4 } from 'uuid';
 import { useAppStore } from '@/store';
@@ -9,6 +9,7 @@ import { WaveformVisualizer } from '@/components/WaveformVisualizer';
 import { AudioRecorder } from '@/utils/audioRecorder';
 import { AudioPlayer } from '@/utils/audioPlayer';
 import { WebSocketManager } from '@/utils/websocket';
+import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
 import type { Message } from '@/types';
 import './App.css';
 
@@ -36,6 +37,7 @@ function App() {
   const wsRef = useRef<WebSocketManager | null>(null);
   const chunkIndexRef = useRef(0);
   const [audioData, setAudioData] = useState<Float32Array | null>(null);
+  const mobileAudioPreparedRef = useRef(false); // 标记移动端音频是否已准备
 
   // 初始化
   useEffect(() => {
@@ -88,9 +90,8 @@ function App() {
 
     switch (message.type) {
       case 'text_result':
-        if (message.isFinal) {
-          setRecognizedText(message.text);
-        }
+        // 显示中间结果和最终结果
+        setRecognizedText(message.text);
         break;
 
       case 'emotion':
@@ -138,9 +139,23 @@ function App() {
   };
 
   // 开始录音
-  const handleStartRecord = () => {
+  const handleStartRecord = async () => {
     if (currentState !== 'idle' && currentState !== 'error') return;
 
+    // 移动端首次使用时准备音频环境(激活扬声器模式)
+    if (isMobileDevice() && !mobileAudioPreparedRef.current) {
+      try {
+        await prepareMobileAudio();
+        requestVolumeControl();
+        mobileAudioPreparedRef.current = true;
+        
+        // 提示移动端用户调高音量
+        antMessage.info('请确保设备音量已调高');
+      } catch (error) {
+        console.error('准备移动端音频失败:', error);
+      }
+    }
+
     const requestId = uuidv4();
     setRequestId(requestId);
     setState('recording');

+ 2 - 2
frontend/src/components/AnimatedCharacter.tsx

@@ -115,12 +115,12 @@ export const AnimatedCharacter: React.FC<AnimatedCharacterProps> = ({ emotion, s
 
   useEffect(() => {
     // 根据状态和情感获取对应的emoji配置
-    let key = state;
+    let key: string = state;
     if (state === 'speaking') {
       key = `speaking_${emotion}`;
     }
 
-    const config = EMOJI_MAP[key] || EMOJI_MAP.idle;
+    const config = EMOJI_MAP[key as keyof typeof EMOJI_MAP] || EMOJI_MAP.idle;
 
     // 添加切换动画效果
     setIsAnimating(true);

+ 77 - 0
frontend/src/utils/audioHelper.ts

@@ -0,0 +1,77 @@
+/**
+ * 移动端音频播放辅助工具
+ * 用于确保音频通过扬声器播放
+ */
+
+/**
+ * 检测是否为移动设备
+ */
+export function isMobileDevice(): boolean {
+  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
+    navigator.userAgent
+  );
+}
+
+/**
+ * 检测是否为 iOS 设备
+ */
+export function isIOS(): boolean {
+  return /iPhone|iPad|iPod/i.test(navigator.userAgent);
+}
+
+/**
+ * 为移动端准备音频播放环境
+ * 必须在用户交互(如点击、触摸)事件中调用
+ */
+export async function prepareMobileAudio(): Promise<void> {
+  if (!isMobileDevice()) {
+    return;
+  }
+
+  // 创建一个静音音频来"激活"扬声器模式
+  const audioContext = new AudioContext();
+  
+  // 创建一个极短的静音音频
+  const buffer = audioContext.createBuffer(1, 1, 22050);
+  const source = audioContext.createBufferSource();
+  source.buffer = buffer;
+  
+  // 创建增益节点并设置为静音
+  const gainNode = audioContext.createGain();
+  gainNode.gain.value = 0.01; // 极小音量
+  
+  source.connect(gainNode);
+  gainNode.connect(audioContext.destination);
+  
+  // 播放静音音频
+  source.start(0);
+  
+  // 恢复 AudioContext
+  if (audioContext.state === 'suspended') {
+    await audioContext.resume();
+  }
+  
+  // 清理
+  setTimeout(() => {
+    audioContext.close();
+  }, 100);
+  
+  console.log('移动端音频环境已准备(扬声器模式)');
+}
+
+/**
+ * 请求移动设备音量控制权限
+ * iOS 特别处理
+ */
+export function requestVolumeControl(): void {
+  if (isIOS()) {
+    // iOS Safari 特殊处理:提醒用户调高音量
+    console.log('iOS 设备检测到:请确保设备音量已调高,并且未开启静音模式');
+    
+    // 尝试使用 Vibration API 来提示用户(如果支持)
+    if ('vibrate' in navigator) {
+      navigator.vibrate(100);
+    }
+  }
+}
+

+ 60 - 7
frontend/src/utils/audioPlayer.ts

@@ -1,10 +1,10 @@
 /**
  * 音频播放器
  * 支持 PCM 流式播放(24kHz, 16bit, Mono)
+ * 优化移动端扬声器播放,提升音量
  */
 export class AudioPlayer {
   private audioContext: AudioContext | null = null;
-  private isPlaying = false;
   private onEndCallback: (() => void) | null = null;
   
   // 流式播放相关
@@ -15,11 +15,58 @@ export class AudioPlayer {
   
   // 播放速度控制
   private playbackRate = 1.0; // 播放速度:1.0 = 正常速度(保持原音)
+  
+  // 音频处理节点(用于音量增强)
+  private gainNode: GainNode | null = null;
+  private compressor: DynamicsCompressorNode | null = null;
+  private masterGain = 2.5; // 主音量增益(2.5倍,可调整)
 
   async initialize(): Promise<void> {
     // 创建 AudioContext,采样率 24000Hz(与后端 TTS 一致)
     this.audioContext = new AudioContext({ sampleRate: 24000 });
-    console.log(`AudioPlayer 初始化 - 采样率: ${this.audioContext.sampleRate}Hz, 播放速度: ${this.playbackRate}x`);
+    
+    // 创建增益节点(用于提升音量)
+    this.gainNode = this.audioContext.createGain();
+    this.gainNode.gain.value = this.masterGain;
+    
+    // 创建动态压缩器(增强响度,防止失真)
+    this.compressor = this.audioContext.createDynamicsCompressor();
+    this.compressor.threshold.value = -24; // 阈值(dB)
+    this.compressor.knee.value = 30; // 拐点
+    this.compressor.ratio.value = 12; // 压缩比
+    this.compressor.attack.value = 0.003; // 启动时间(秒)
+    this.compressor.release.value = 0.25; // 释放时间(秒)
+    
+    // 连接音频处理链:GainNode -> Compressor -> Destination
+    this.gainNode.connect(this.compressor);
+    this.compressor.connect(this.audioContext.destination);
+    
+    // 确保 AudioContext 在移动端恢复运行
+    await this.resumeAudioContext();
+    
+    console.log(`AudioPlayer 初始化 - 采样率: ${this.audioContext.sampleRate}Hz, 播放速度: ${this.playbackRate}x, 音量增益: ${this.masterGain}x`);
+  }
+
+  /**
+   * 恢复 AudioContext(移动端需要用户交互后才能播放)
+   */
+  private async resumeAudioContext(): Promise<void> {
+    if (this.audioContext && this.audioContext.state === 'suspended') {
+      await this.audioContext.resume();
+      console.log('AudioContext 已恢复运行');
+    }
+  }
+  
+  /**
+   * 设置主音量增益
+   * @param gain 增益倍数(1.0 = 原音量,2.0 = 2倍音量,建议范围 1.0-4.0)
+   */
+  setGain(gain: number): void {
+    this.masterGain = Math.max(0.5, Math.min(5.0, gain)); // 限制范围 0.5-5.0
+    if (this.gainNode) {
+      this.gainNode.gain.value = this.masterGain;
+      console.log(`音量增益已设置为: ${this.masterGain}x`);
+    }
   }
 
   /**
@@ -64,13 +111,15 @@ export class AudioPlayer {
   /**
    * 处理音频队列(实现无缝播放)
    */
-  private processQueue(): void {
+  private async processQueue(): Promise<void> {
     if (this.isProcessingQueue || this.audioQueue.length === 0 || !this.audioContext) {
       return;
     }
 
+    // 确保 AudioContext 处于运行状态(移动端关键)
+    await this.resumeAudioContext();
+
     this.isProcessingQueue = true;
-    this.isPlaying = true;
 
     // 获取当前时间
     const currentTime = this.audioContext.currentTime;
@@ -87,7 +136,13 @@ export class AudioPlayer {
       const source = this.audioContext.createBufferSource();
       source.buffer = audioBuffer;
       source.playbackRate.value = this.playbackRate; // 🔥 设置播放速度
-      source.connect(this.audioContext.destination);
+      
+      // 🔥 连接到增益节点而不是直接连接到 destination(实现音量增强)
+      if (this.gainNode) {
+        source.connect(this.gainNode);
+      } else {
+        source.connect(this.audioContext.destination);
+      }
       
       // 在队列中的下一个时间点开始播放(实现无缝衔接)
       source.start(this.nextStartTime);
@@ -107,7 +162,6 @@ export class AudioPlayer {
         
         // 如果所有音频都播放完了,触发结束回调
         if (this.currentSources.length === 0 && this.audioQueue.length === 0) {
-          this.isPlaying = false;
           if (this.onEndCallback) {
             this.onEndCallback();
           }
@@ -148,7 +202,6 @@ export class AudioPlayer {
     
     this.currentSources = [];
     this.audioQueue = [];
-    this.isPlaying = false;
     this.nextStartTime = 0;
   }
 

+ 1 - 1
frontend/src/utils/audioRecorder.ts

@@ -16,7 +16,7 @@ export class AudioRecorder {
   // 音频缓冲区:累积音频数据后再发送,避免发送过小的块
   private audioBuffer: Int16Array[] = [];
   private readonly BUFFER_SIZE_MS = 200; // 每200ms发送一次
-  private bufferTimer: NodeJS.Timeout | null = null;
+  private bufferTimer: ReturnType<typeof setInterval> | null = null;
 
   async initialize(): Promise<void> {
     try {

+ 1 - 1
frontend/src/utils/websocket.ts

@@ -8,7 +8,7 @@ import type { Message } from '@/types';
 export class WebSocketManager {
   private ws: ReconnectingWebSocket | null = null;
   private messageHandlers: Map<string, (message: Message) => void> = new Map();
-  private pingInterval: NodeJS.Timeout | null = null;
+  private pingInterval: ReturnType<typeof setInterval> | null = null;
   private readonly PING_INTERVAL = 30000; // 30秒
 
   connect(url: string, onConnectionChange?: (connected: boolean) => void): void {

+ 10 - 0
frontend/src/vite-env.d.ts

@@ -0,0 +1,10 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly VITE_WS_URL?: string;
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv;
+}
+

+ 22 - 4
frontend/vite.config.ts

@@ -3,6 +3,27 @@ import react from '@vitejs/plugin-react';
 import path from 'path';
 import fs from 'fs';
 
+// SSL证书配置
+// 优先使用环境变量指定的证书路径(生产环境)
+const sslKeyPath = process.env.VITE_SSL_KEYFILE || path.resolve(__dirname, '../ssl/key.pem');
+const sslCertPath = process.env.VITE_SSL_CERTFILE || path.resolve(__dirname, '../ssl/cert.pem');
+
+// 检查证书文件是否存在
+let httpsConfig: any = false;
+if (fs.existsSync(sslKeyPath) && fs.existsSync(sslCertPath)) {
+  httpsConfig = {
+    key: fs.readFileSync(sslKeyPath),
+    cert: fs.readFileSync(sslCertPath),
+  };
+  console.log('✅ 使用SSL证书:');
+  console.log(`  证书: ${sslCertPath}`);
+  console.log(`  私钥: ${sslKeyPath}`);
+} else {
+  console.log('⚠️  SSL证书不存在,以HTTP模式启动');
+  console.log(`  证书路径: ${sslCertPath}`);
+  console.log(`  私钥路径: ${sslKeyPath}`);
+}
+
 export default defineConfig({
   plugins: [react()],
   resolve: {
@@ -13,10 +34,7 @@ export default defineConfig({
   server: {
     port: 3000,
     host: '0.0.0.0',  // 允许外部访问
-    https: {
-      key: fs.readFileSync(path.resolve(__dirname, '../ssl/key.pem')),
-      cert: fs.readFileSync(path.resolve(__dirname, '../ssl/cert.pem')),
-    },
+    https: httpsConfig,
   },
 });
 

+ 168 - 0
环境配置指南.md

@@ -0,0 +1,168 @@
+# 环境配置指南
+
+## 前端配置
+
+### 1. 创建环境配置文件
+
+在 `frontend/` 目录手动创建 `.env` 文件:
+
+#### 开发环境
+```bash
+cd frontend
+```
+
+创建 `.env` 文件,内容:
+```bash
+# WebSocket URL - 本地开发
+VITE_WS_URL=wss://localhost:8000/ws
+
+# SSL证书(留空使用本地证书)
+VITE_SSL_KEYFILE=
+VITE_SSL_CERTFILE=
+```
+
+#### 生产环境
+在服务器上,在 `frontend/` 目录创建 `.env.production` 文件:
+```bash
+# WebSocket URL - 生产环境
+VITE_WS_URL=wss://www.hikelly.chat/ws
+
+# SSL证书(Nginx管理,留空)
+VITE_SSL_KEYFILE=
+VITE_SSL_CERTFILE=
+```
+
+### 2. WebSocket URL 说明
+
+根据您的 Nginx 配置:
+- **域名访问**: `wss://www.hikelly.chat/ws`
+- **IP访问**: `wss://101.201.60.170/ws`(需要配置DNS或证书)
+
+**推荐使用域名访问**,因为SSL证书绑定的是域名 `www.hikelly.chat`。
+
+### 3. 启动前端
+
+开发环境:
+```bash
+npm run dev
+```
+
+生产环境构建:
+```bash
+npm run build
+```
+
+## 后端配置
+
+### 1. 创建环境配置文件
+
+在 `backend/` 目录手动创建 `.env` 文件:
+
+#### 开发环境
+```bash
+# 服务器配置
+HOST=0.0.0.0
+PORT=8000
+
+# SSL证书(留空使用本地ssl/目录)
+SSL_KEYFILE=
+SSL_CERTFILE=
+
+# API密钥
+DASHSCOPE_API_KEY=your_api_key_here
+DOUBAO_API_KEY=your_api_key_here
+DOUBAO_APP_ID=your_app_id_here
+```
+
+#### 生产环境(使用Nginx代理,推荐)
+
+**重要**:变量名必须大写(`PORT` 不是 `port`)
+
+```bash
+# 服务器配置(Nginx代理到此端口)
+# 注意:PORT必须大写!
+HOST=127.0.0.1
+PORT=8766
+
+# SSL证书(Nginx管理,留空)
+SSL_KEYFILE=
+SSL_CERTFILE=
+
+# API密钥
+DASHSCOPE_API_KEY=your_actual_api_key
+DOUBAO_API_KEY=your_actual_api_key
+DOUBAO_APP_ID=your_actual_app_id
+```
+
+**快速创建配置文件**:
+```bash
+cd backend
+cp .env.template .env
+# 然后编辑 .env 填入实际的API密钥
+```
+
+### 2. 启动后端
+
+开发环境:
+```bash
+cd backend
+python main.py
+```
+
+生产环境(使用 systemd 或 supervisor 管理):
+```bash
+cd backend
+python main.py
+```
+
+## 注意事项
+
+### 生产环境架构
+
+根据您的 Nginx 配置,架构如下:
+
+```
+客户端
+  ↓ HTTPS (wss://www.hikelly.chat/ws)
+Nginx (443端口)
+  ↓ HTTP (ws://127.0.0.1:8766/ws)
+后端服务 (8766端口)
+```
+
+**关键点**:
+1. ✅ 后端监听 `127.0.0.1:8766`(仅本地访问)
+2. ✅ 后端不需要配置SSL(Nginx统一管理)
+3. ✅ 前端通过域名访问(`wss://www.hikelly.chat/ws`)
+4. ✅ Nginx负责SSL终止和WebSocket升级
+
+### 端口配置
+
+- **开发环境后端**: 8000(本地调试)
+- **生产环境后端**: 8766(Nginx配置中的代理目标)
+- **前端开发服务**: 3000(Vite默认)
+- **Nginx**: 443(HTTPS)
+
+### 证书管理
+
+- **开发环境**: 使用 `ssl/` 目录的自签名证书
+- **生产环境**: 由Nginx统一管理(`/etc/nginx/cert/www.hikelly.chat.*`)
+
+## 快速检查清单
+
+### 开发环境
+- [ ] 生成本地SSL证书 (`cd ssl && openssl req ...`)
+- [ ] 创建 `frontend/.env` 配置WebSocket URL
+- [ ] 创建 `backend/.env` 配置API密钥
+- [ ] 启动后端 (`cd backend && python main.py`)
+- [ ] 启动前端 (`cd frontend && npm run dev`)
+- [ ] 访问 `https://localhost:3000`
+
+### 生产环境
+- [ ] 确认Nginx配置正确
+- [ ] 创建 `backend/.env` 设置端口8766
+- [ ] 后端以HTTP模式运行(不配置SSL)
+- [ ] 前端构建 (`npm run build`)
+- [ ] 配置前端 `.env.production` 使用 `wss://www.hikelly.chat/ws`
+- [ ] Nginx托管前端静态文件
+- [ ] 访问 `https://www.hikelly.chat`
+