Update code
This commit is contained in:
16
test2/.gitignore
vendored
Normal file
16
test2/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
# 构建产物
|
||||
client/dist/
|
||||
|
||||
# 环境变量(含密钥,手动配置)
|
||||
server/.env
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
server/fc_chunks_debug.json
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
440
test2/ARCHITECTURE.md
Normal file
440
test2/ARCHITECTURE.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# BigWo 智能语音对话系统 — 系统架构文档
|
||||
|
||||
> 版本:1.0 | 更新日期:2026-03-09
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统概述
|
||||
|
||||
BigWo 是一个**企业级智能客服对话系统**,支持**语音通话**和**文字对话**两种交互模式,可无缝切换且保持上下文连续。
|
||||
|
||||
### 核心能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 实时语音对话 | 火山引擎 RTC + S2S 端到端语音大模型,混合编排模式 |
|
||||
| 知识库问答 | Function Calling → 方舟私域知识库 → 本地知识库 |
|
||||
| 文字对话 | Coze v3 Chat API,支持 SSE 流式输出 |
|
||||
| 语音↔文字切换 | 同一 sessionId 贯穿,MySQL 持久化完整历史 |
|
||||
| 工具调用 | 知识库检索、天气、订单、时间、计算 |
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **前端**:React 18 + Vite 5 + TailwindCSS 4 + Lucide Icons
|
||||
- **后端**:Node.js + Express 4 (port 3012)
|
||||
- **语音**:@volcengine/rtc SDK + S2S 端到端 + 方舟 LLM (API v2024-12-01)
|
||||
- **文字**:Coze v3 Chat API(流式 SSE)
|
||||
- **知识库**:方舟 Chat Completions API + knowledge_base metadata
|
||||
- **数据库**:MySQL 8 (mysql2/promise)
|
||||
- **部署**:PM2 (cluster) + Nginx 反向代理 + 宝塔面板
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 浏览器客户端 │
|
||||
│ ┌────────────┐ ┌───────────┐ ┌──────────────────┐ │
|
||||
│ │ VoicePanel │ │ ChatPanel │ │ SettingsPanel │ │
|
||||
│ └─────┬──────┘ └─────┬─────┘ └──────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────▼──────┐ ┌─────▼─────┐ ┌────────────────┐ │
|
||||
│ │useVoiceChat│ │ chatApi │ │ voiceApi │ │
|
||||
│ │ (Hook) │ │ (HTTP/SSE)│ │ (HTTP 封装) │ │
|
||||
│ └─────┬──────┘ └─────┬─────┘ └────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────▼──────┐ │ │
|
||||
│ │ rtcService │ │ │
|
||||
│ │(WebRTC SDK)│ │ │
|
||||
│ └─────┬──────┘ │ │
|
||||
└────────┼───────────────┼────────────────────────────────┘
|
||||
│ WebRTC │ HTTPS
|
||||
│ 音频流 │ REST/SSE
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌─────────────────────────────────────┐
|
||||
│ 火山引擎 RTC │ │ Express 后端 (port 3012) │
|
||||
│ 云端服务 │ │ │
|
||||
│ ┌────────────┐ │ │ routes/voice.js — 语音全生命周期 │
|
||||
│ │S2S 端到端 │ │ │ routes/chat.js — 文字对话 │
|
||||
│ │语音大模型 │ │ │ routes/session.js — 会话管理 │
|
||||
│ ├────────────┤ │ │ │
|
||||
│ │方舟 LLM │ │ │ services/volcengine.js — OpenAPI │
|
||||
│ │(工具决策) │ │ │ services/toolExecutor.js — 工具 │
|
||||
│ └─────┬──────┘ │ │ services/cozeChatService.js — Coze │
|
||||
│ │FC 回调 │ │ services/arkChatService.js — 方舟 │
|
||||
│ │(HTTP) │ │ config/voiceChatConfig.js — 配置 │
|
||||
│ ▼ │ │ db/index.js — MySQL │
|
||||
│ fc_callback ──►│ │ │
|
||||
│ ◄── Update ────│ └─────────────────────────────────────┘
|
||||
└────────────────┘ │
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────┐
|
||||
│ MySQL 8 │ │ 方舟知识库 │
|
||||
│ sessions 表 │ │ (远程 API) │
|
||||
│ messages 表 │ └──────────────┘
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
test2/
|
||||
├── client/ # 前端(React + Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── App.jsx # 主应用,语音/文字模式切换
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── VoicePanel.jsx # 语音通话界面
|
||||
│ │ │ ├── ChatPanel.jsx # 文字对话界面(SSE 流式)
|
||||
│ │ │ ├── SettingsPanel.jsx # 语音参数设置面板
|
||||
│ │ │ └── SubtitleDisplay.jsx# 实时字幕展示
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useVoiceChat.js # 语音通话核心 Hook
|
||||
│ │ └── services/
|
||||
│ │ ├── rtcService.js # RTC SDK 封装(WebRTC)
|
||||
│ │ ├── voiceApi.js # 语音 HTTP 请求
|
||||
│ │ └── chatApi.js # 文字 HTTP/SSE 请求
|
||||
│ └── vite.config.js
|
||||
├── server/ # 后端(Express)
|
||||
│ ├── app.js # 入口 + FC 回调 raw body 解析
|
||||
│ ├── routes/
|
||||
│ │ ├── voice.js # 语音全生命周期 + FC 回调(核心)
|
||||
│ │ ├── chat.js # 文字对话(Coze)
|
||||
│ │ └── session.js # 会话历史 & 模式切换
|
||||
│ ├── services/
|
||||
│ │ ├── volcengine.js # 火山引擎 OpenAPI 签名调用
|
||||
│ │ ├── toolExecutor.js # 工具执行器
|
||||
│ │ ├── arkChatService.js # 方舟 LLM(文字场景备选)
|
||||
│ │ └── cozeChatService.js # Coze Chat API(文字主服务)
|
||||
│ ├── config/
|
||||
│ │ ├── voiceChatConfig.js # StartVoiceChat 配置构建器
|
||||
│ │ └── tools.js # FC 工具定义(5 个工具)
|
||||
│ ├── db/index.js # MySQL CRUD
|
||||
│ ├── lib/token.js # RTC Token 生成
|
||||
│ └── .env # 环境变量
|
||||
└── ecosystem.config.js # PM2 部署配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 语音通话模块
|
||||
|
||||
### 4.1 混合编排模式(OutputMode=1)
|
||||
|
||||
S2S 端到端模型处理普通闲聊(低延迟 ~300-800ms),方舟 LLM 同时决策是否需要调用工具。两者并行运行。
|
||||
|
||||
### 4.2 会话生命周期
|
||||
|
||||
```
|
||||
POST /prepare → 创建房间 + 生成 RTC Token + 分配 TaskId
|
||||
↓
|
||||
客户端 joinRoom() → 用户进入 RTC 房间、开启麦克风
|
||||
↓
|
||||
POST /start → 构建配置 → StartVoiceChat API → AI Bot 进房
|
||||
↓
|
||||
实时语音对话(S2S 直接回复 + LLM 工具决策)
|
||||
↓
|
||||
POST /stop → StopVoiceChat API → 返回字幕 → 可切换文字模式
|
||||
```
|
||||
|
||||
### 4.3 语音 API 端点(voice.js)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/voice/config` | GET | 获取模型、音色列表 |
|
||||
| `/api/voice/prepare` | POST | 创建房间、生成 Token |
|
||||
| `/api/voice/start` | POST | 启动 AI 语音对话 |
|
||||
| `/api/voice/stop` | POST | 停止对话、返回字幕 |
|
||||
| `/api/voice/fc_callback` | POST | FC 回调(RTC 服务端→服务端) |
|
||||
| `/api/voice/subtitle` | POST | 客户端转发确认字幕 |
|
||||
| `/api/voice/room_message` | POST | 客户端转发 RTC 房间消息 |
|
||||
|
||||
### 4.4 内存数据映射
|
||||
|
||||
voice.js 维护以下 Map 用于会话状态关联:
|
||||
|
||||
| Map 名称 | Key | Value | 用途 |
|
||||
|----------|-----|-------|------|
|
||||
| `activeSessions` | sessionId | 完整会话对象 | 会话生命周期管理 |
|
||||
| `roomToBotUserId` | roomId | botUserId | FC 回调→UpdateVoiceChat |
|
||||
| `roomToHumanUserId` | roomId | userId | 日志追踪 |
|
||||
| `roomToSessionId` | roomId | sessionId | DB 写入关联 |
|
||||
| `roomToTaskId` | roomId | taskId | **UpdateVoiceChat 必须用此 TaskId** |
|
||||
| `latestUserSpeech` | roomId | {text, timestamp} | FC 参数解析兜底 |
|
||||
| `toolCallBuffers` | TaskID | buffer 对象 | FC chunk 收集 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Function Calling 回调处理(核心)
|
||||
|
||||
### 5.1 数据流
|
||||
|
||||
```
|
||||
RTC 服务 (LLM 触发 tool_call)
|
||||
│ HTTP POST(无 Content-Type,body 为 JSON)
|
||||
▼
|
||||
app.js 手动读取 raw body → JSON.parse → 分配 _seq 序列号
|
||||
│
|
||||
▼
|
||||
voice.js fc_callback 路由
|
||||
│
|
||||
├─ FormatA: Type="tool_calls" → OpenAI 格式数组,流式 chunk
|
||||
├─ FormatB: Type="information" → RTC 原生格式
|
||||
└─ FormatC: 会话状态回调 → 记录日志
|
||||
│
|
||||
▼(FormatA 为主)
|
||||
chunk 缓冲收集(toolCallBuffers Map,1s 超时触发)
|
||||
│
|
||||
▼ 1s 后
|
||||
参数解析尝试链:
|
||||
① JSON.parse(拼接 chunks)
|
||||
② latestUserSpeech(ASR 用户语音兜底)
|
||||
③ extractReadableText(从 chunks 提取中文字符)
|
||||
│
|
||||
▼
|
||||
发送 interrupt 打断 S2S 直接回复
|
||||
│ UpdateVoiceChat({ Command: "interrupt", TaskId: sessionTaskId })
|
||||
▼
|
||||
执行工具(toolExecutor.js)
|
||||
│ search_knowledge: 方舟 KB(30s) → 本地 KB
|
||||
▼
|
||||
回传结果
|
||||
│ UpdateVoiceChat({
|
||||
│ Command: "function",
|
||||
│ TaskId: sessionTaskId, ← 必须是 StartVoiceChat 的 TaskId
|
||||
│ Message: JSON.stringify({ ToolCallID, Content })
|
||||
│ })
|
||||
▼
|
||||
AI 用知识库内容语音回复
|
||||
```
|
||||
|
||||
### 5.2 关键设计决策
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|----------|
|
||||
| FC 回调无 Content-Type | app.js 在 express.json() 之前手动读取 raw body |
|
||||
| Chunk 乱序且不完整 | 1s 定时器收集全部 chunks 后拼接 |
|
||||
| JSON.parse 失败 | 用 ASR 用户语音文本作为查询参数(方案B) |
|
||||
| S2S 直接回复覆盖 FC 结果 | 先发 interrupt 打断,再发 function 结果 |
|
||||
| TaskId 不匹配 | roomToTaskId 存储 StartVoiceChat 的 TaskId |
|
||||
| HTTP 响应超时 | 立即返回 200,异步执行工具 |
|
||||
|
||||
### 5.3 用户语音文本获取(方案B)
|
||||
|
||||
FC 回调的 arguments 经常乱序无法解析,因此需要从其他途径获取用户的原始问题:
|
||||
|
||||
```
|
||||
客户端 RTC SDK
|
||||
│ onSubtitleMessageReceived / onRoomBinaryMessageReceived
|
||||
▼
|
||||
useVoiceChat.js
|
||||
│ 转发 definite=true 的用户字幕
|
||||
▼
|
||||
POST /api/voice/subtitle → latestUserSpeech.set(roomId, text)
|
||||
POST /api/voice/room_message → 解析二进制消息中的字幕数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 文字对话模块
|
||||
|
||||
### 6.1 架构
|
||||
|
||||
通过 **Coze v3 Chat API** 实现,Coze Bot 内置知识库插件。
|
||||
|
||||
```
|
||||
用户输入 → POST /api/chat/send-stream
|
||||
↓
|
||||
cozeChatService.chatStream()
|
||||
│ 首次对话注入语音历史作为上下文
|
||||
│ Coze 自动管理 conversation_id
|
||||
↓
|
||||
SSE 流式返回 → 前端逐字展示
|
||||
```
|
||||
|
||||
### 6.2 文字 API 端点(chat.js)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/chat/start` | POST | 创建会话,注入语音历史上下文 |
|
||||
| `/api/chat/send` | POST | 非流式发送 |
|
||||
| `/api/chat/send-stream` | POST | SSE 流式发送 |
|
||||
| `/api/chat/history/:id` | GET | 获取会话状态 |
|
||||
| `/api/chat/:id` | DELETE | 删除会话 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 会话管理与模式切换
|
||||
|
||||
### 7.1 统一 sessionId
|
||||
|
||||
同一个 sessionId 贯穿语音和文字模式,所有消息持久化到 MySQL messages 表。
|
||||
|
||||
### 7.2 消息来源标记
|
||||
|
||||
| source 值 | 说明 |
|
||||
|-----------|------|
|
||||
| `voice_asr` | 语音 ASR 识别的用户文本 |
|
||||
| `voice_bot` | AI 语音回复字幕 |
|
||||
| `voice_tool` | 语音场景工具调用结果 |
|
||||
| `chat_user` | 文字对话用户输入 |
|
||||
| `chat_bot` | 文字对话 AI 回复 |
|
||||
|
||||
### 7.3 模式切换 API(session.js)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/session/:id/history` | GET | 获取完整历史(支持 llm/full 格式) |
|
||||
| `/api/session/:id/switch` | POST | 切换模式,返回上下文历史 |
|
||||
|
||||
### 7.4 数据库表
|
||||
|
||||
**sessions**:`id(PK)`, `user_id`, `mode(voice/chat)`, `created_at`, `updated_at`
|
||||
|
||||
**messages**:`id(AI PK)`, `session_id`, `role(user/assistant/tool/system)`, `content`, `source`, `tool_name`, `created_at`
|
||||
|
||||
---
|
||||
|
||||
## 8. 客户端组件
|
||||
|
||||
### 8.1 组件树
|
||||
|
||||
```
|
||||
App.jsx # 模式切换 + 全局设置
|
||||
├── VoicePanel.jsx # 语音通话 UI(开始/结束/静音/时长)
|
||||
│ └── SubtitleDisplay # 实时字幕(definite/interim 区分)
|
||||
├── ChatPanel.jsx # 文字对话 UI(消息列表 + SSE 流式显示)
|
||||
└── SettingsPanel.jsx # 设置面板(模型/音色/系统角色/VAD)
|
||||
```
|
||||
|
||||
### 8.2 useVoiceChat Hook
|
||||
|
||||
管理语音通话完整生命周期:
|
||||
|
||||
- **start(options)**:prepare → joinRoom → startVoiceChat
|
||||
- **stop()**:leaveRoom → stopVoiceChat → 返回字幕
|
||||
- **toggleMute()**:静音/取消静音
|
||||
- **状态**:isActive, isMuted, isConnecting, subtitles, duration, error
|
||||
|
||||
### 8.3 rtcService.js
|
||||
|
||||
封装 @volcengine/rtc SDK:
|
||||
|
||||
- **init(appId)**:创建引擎、注册事件监听
|
||||
- **joinRoom()**:入房 + 开始音频采集 + 启用字幕
|
||||
- **事件监听**:字幕(onSubtitleMessageReceived)、房间消息(binary/text)、诊断(音量/流)
|
||||
- **方案B 消息转发**:所有房间消息 → onRoomMessage 回调 → useVoiceChat → 后端
|
||||
|
||||
---
|
||||
|
||||
## 9. 环境变量
|
||||
|
||||
### 必需
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `VOLC_RTC_APP_ID` | RTC 应用 ID |
|
||||
| `VOLC_RTC_APP_KEY` | RTC 应用密钥(生成 Token) |
|
||||
| `VOLC_ACCESS_KEY_ID` | 火山引擎 AK(API 签名) |
|
||||
| `VOLC_SECRET_ACCESS_KEY` | 火山引擎 SK |
|
||||
| `VOLC_S2S_APP_ID` | S2S 端到端语音 AppID |
|
||||
| `VOLC_S2S_TOKEN` | S2S Token |
|
||||
| `VOLC_ARK_ENDPOINT_ID` | 方舟 LLM 推理接入点 ID |
|
||||
|
||||
### 可选
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `COZE_API_TOKEN` | Coze 智能体 Token(文字对话) |
|
||||
| `COZE_BOT_ID` | Coze Bot ID |
|
||||
| `VOLC_ARK_KNOWLEDGE_BASE_IDS` | 方舟私域知识库数据集 ID(逗号分隔) |
|
||||
| `VOLC_ARK_API_KEY` | 方舟 API Key |
|
||||
| `VOLC_WEBSEARCH_API_KEY` | 联网搜索 Key |
|
||||
| `FC_SERVER_URL` | FC 回调地址 |
|
||||
| `FC_SIGNATURE` | FC 回调签名 |
|
||||
| `MYSQL_HOST/PORT/USER/PASSWORD/DATABASE` | MySQL 配置 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 部署架构
|
||||
|
||||
```
|
||||
互联网用户
|
||||
│
|
||||
▼ HTTPS (443)
|
||||
┌──────────────┐
|
||||
│ Nginx │ ← 宝塔面板管理
|
||||
│ (反向代理) │
|
||||
│ SSL 终止 │
|
||||
└──────┬───────┘
|
||||
│ http://localhost:3012
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ PM2 │ ← ecosystem.config.js
|
||||
│ bigwo-server│
|
||||
│ (Node.js) │
|
||||
└──────┬───────┘
|
||||
│
|
||||
├── MySQL 8 (localhost:3306)
|
||||
├── 火山引擎 RTC API (rtc.volcengineapi.com)
|
||||
├── 方舟 LLM API (ark.cn-beijing.volces.com)
|
||||
└── Coze API (api.coze.cn)
|
||||
```
|
||||
|
||||
### PM2 配置
|
||||
|
||||
- 进程名:`bigwo-server`
|
||||
- 工作目录:`/www/wwwroot/demo.tensorgrove.com.cn/server`
|
||||
- 日志路径:`/var/log/bigwo/server-out.log`、`server-error.log`
|
||||
- 内存限制:512M 自动重启
|
||||
|
||||
---
|
||||
|
||||
## 11. 工具定义(tools.js)
|
||||
|
||||
系统定义了 5 个 Function Calling 工具:
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `search_knowledge` | query: string | **核心工具**,强制优先调用 |
|
||||
| `query_weather` | city: string | 天气查询(Mock) |
|
||||
| `query_order` | order_id: string | 订单查询(Mock) |
|
||||
| `get_current_time` | 无 | 当前时间 |
|
||||
| `calculate` | expression: string | 数学计算 |
|
||||
|
||||
### search_knowledge 查询链
|
||||
|
||||
```
|
||||
方舟私域知识库 (30s 超时)
|
||||
│ POST https://ark.cn-beijing.volces.com/api/v3/chat/completions
|
||||
│ metadata.knowledge_base: { dataset_ids, top_k: 3, threshold: 0.5 }
|
||||
│
|
||||
│ 失败 ↓
|
||||
▼
|
||||
本地知识库 (即时,关键词匹配)
|
||||
│ 覆盖:退货、退款、配送、保修、会员
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 安全与限制
|
||||
|
||||
- **API 签名**:所有火山引擎 API 调用使用 AK/SK HMAC 签名
|
||||
- **FC 回调签名**:ServerSignature 校验(当前信任模式)
|
||||
- **计算工具防注入**:仅允许 `0-9 + - * / ( ) . %` 字符
|
||||
- **CORS**:已开启(`cors()` 中间件)
|
||||
- **Body 限制**:1MB
|
||||
- **会话过期**:文字对话 30 分钟自动清理
|
||||
- **环境变量**:敏感信息存于 `.env`,启动时校验
|
||||
|
||||
---
|
||||
|
||||
## 13. 已知问题与优化方向
|
||||
|
||||
1. **FC 响应延迟**:用户提问到 AI 用知识库回答约需 12-15s(LLM 决策 ~8s + KB 查询 ~5s),期间有静默
|
||||
2. **Chunk 乱序**:RTC FC 回调的 tool_call arguments 被拆成单字符 chunk 且乱序,只能靠 ASR 文本兜底
|
||||
3. **S2S 与 LLM 并行冲突**:S2S 会先给出直接回复,需 interrupt 打断后再发 FC 结果
|
||||
4. **Mock 工具**:天气和订单工具目前为 Mock 数据,可接入真实 API
|
||||
5. **知识库冷启动**:方舟 KB 首次查询较慢(~10s),后续查询 ~3-5s
|
||||
201
test2/DEPLOY_BAOTA.md
Normal file
201
test2/DEPLOY_BAOTA.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 宝塔面板部署指南 — BigWo 语音/文字聊天
|
||||
|
||||
> 域名: `demo.tensorgrove.com.cn`
|
||||
> 系统: TencentOS 4
|
||||
> 架构: Nginx + Node.js + MySQL
|
||||
|
||||
---
|
||||
|
||||
## 一、宝塔面板准备
|
||||
|
||||
### 1.1 安装必要软件(宝塔 → 软件商店)
|
||||
- **Nginx**(一般已安装)
|
||||
- **MySQL 5.7 / 8.0**
|
||||
- **PM2 管理器**(软件商店搜索 "PM2",一键安装)
|
||||
|
||||
### 1.2 安装 Node.js(PM2 管理器 → 设置 → Node 版本)
|
||||
- 选择 **Node.js 20.x** 安装
|
||||
|
||||
---
|
||||
|
||||
## 二、上传项目文件
|
||||
|
||||
### 2.1 上传
|
||||
宝塔 → 文件 → 进入 `/www/wwwroot/`,创建文件夹 `demo.tensorgrove.com.cn`
|
||||
|
||||
将本地 `test2` 项目中的以下内容上传到 `/www/wwwroot/demo.tensorgrove.com.cn/`:
|
||||
```
|
||||
demo.tensorgrove.com.cn/
|
||||
├── client/ ← 整个前端目录
|
||||
├── server/ ← 整个后端目录
|
||||
└── ecosystem.config.js
|
||||
```
|
||||
|
||||
> 可以用宝塔文件管理器直接上传压缩包,然后解压
|
||||
|
||||
### 2.2 安装依赖(宝塔 → 终端)
|
||||
|
||||
```bash
|
||||
# 后端依赖
|
||||
cd /www/wwwroot/demo.tensorgrove.com.cn/server
|
||||
npm install --production
|
||||
npm install mysql2
|
||||
|
||||
# 前端依赖 & 构建
|
||||
cd /www/wwwroot/demo.tensorgrove.com.cn/client
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建完成后 `client/dist/` 目录就是前端静态文件。
|
||||
|
||||
---
|
||||
|
||||
## 三、配置 MySQL
|
||||
|
||||
### 3.1 创建数据库
|
||||
宝塔 → 数据库 → 添加数据库:
|
||||
- 数据库名: `bigwo_chat`
|
||||
- 用户名: `bigwo`
|
||||
- 密码: 自己设置(记住)
|
||||
- 权限: 本地服务器
|
||||
|
||||
### 3.2 修改 .env
|
||||
编辑 `/www/wwwroot/demo.tensorgrove.com.cn/server/.env`,更新 MySQL 配置:
|
||||
|
||||
```env
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=bigwo
|
||||
MYSQL_PASSWORD=你设置的密码
|
||||
MYSQL_DATABASE=bigwo_chat
|
||||
```
|
||||
|
||||
**同时更新 FC 回调地址:**
|
||||
```env
|
||||
FC_SERVER_URL=https://demo.tensorgrove.com.cn/api/voice/fc_callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、宝塔添加站点
|
||||
|
||||
### 4.1 添加站点
|
||||
宝塔 → 网站 → 添加站点:
|
||||
- 域名: `demo.tensorgrove.com.cn`
|
||||
- 根目录: `/www/wwwroot/demo.tensorgrove.com.cn/client/dist`
|
||||
- PHP 版本: **纯静态**
|
||||
- 数据库: 不创建(已在上一步创建)
|
||||
|
||||
### 4.2 申请 SSL 证书
|
||||
网站 → `demo.tensorgrove.com.cn` → SSL → Let's Encrypt:
|
||||
- 勾选域名 → 申请
|
||||
- 开启 **强制 HTTPS**
|
||||
|
||||
### 4.3 配置 Nginx 反向代理
|
||||
网站 → `demo.tensorgrove.com.cn` → 配置文件
|
||||
|
||||
找到 `server {}` 块,在已有的配置中**添加以下内容**(放在 `location / {}` 之前):
|
||||
|
||||
```nginx
|
||||
# ===== 后端 API 反向代理 =====
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3012;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
|
||||
# SSE 流式输出支持(文字聊天)
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# 静态资源长缓存
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
确保 `location / {}` 块中有 SPA 回退(宝塔默认没有,需手动加):
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
保存后点击 **保存** 按钮(宝塔会自动 reload Nginx)。
|
||||
|
||||
---
|
||||
|
||||
## 五、PM2 启动后端
|
||||
|
||||
### 方式 A:宝塔 PM2 管理器(推荐)
|
||||
软件商店 → PM2 管理器 → 设置 → 添加项目:
|
||||
- 项目路径: `/www/wwwroot/demo.tensorgrove.com.cn/server`
|
||||
- 启动文件: `app.js`
|
||||
- 项目名称: `demo.tensorgrove.com.cn-server`
|
||||
|
||||
### 方式 B:命令行
|
||||
```bash
|
||||
cd /www/wwwroot/demo.tensorgrove.com.cn
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验证
|
||||
|
||||
### 6.1 检查后端
|
||||
```bash
|
||||
curl http://127.0.0.1:3012/api/voice/config
|
||||
# 应返回 JSON 配置
|
||||
```
|
||||
|
||||
### 6.2 检查前端
|
||||
浏览器访问 `https://demo.tensorgrove.com.cn`
|
||||
|
||||
### 6.3 检查 API 代理
|
||||
浏览器访问 `https://demo.tensorgrove.com.cn/api/voice/config`
|
||||
|
||||
### 6.4 检查 FC 回调
|
||||
火山引擎 RTC 控制台的 FC 回调地址更新为:
|
||||
```
|
||||
https://demo.tensorgrove.com.cn/api/voice/fc_callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、常用运维命令
|
||||
|
||||
```bash
|
||||
# 查看后端日志
|
||||
pm2 logs demo.tensorgrove.com.cn-server
|
||||
|
||||
# 重启后端
|
||||
pm2 restart demo.tensorgrove.com.cn-server
|
||||
|
||||
# 重新构建前端
|
||||
cd /www/wwwroot/demo.tensorgrove.com.cn/client
|
||||
npm run build
|
||||
|
||||
# 查看 Nginx 错误日志
|
||||
tail -f /www/wwwlogs/demo.tensorgrove.com.cn.error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **域名 DNS 解析**:确保 `demo.tensorgrove.com.cn` A 记录指向服务器公网 IP
|
||||
2. **防火墙**:宝塔 → 安全 → 放行端口 80、443(一般默认已放行)
|
||||
3. **端口 3012 不需要对外开放**,Nginx 内部转发即可
|
||||
4. **FC_SERVER_URL** 必须改为 `https://demo.tensorgrove.com.cn/api/voice/fc_callback`(不再用 ngrok)
|
||||
5. **MySQL 密码** 生产环境务必使用强密码
|
||||
183
test2/FC_CALLBACK_FIX.md
Normal file
183
test2/FC_CALLBACK_FIX.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# FC 回调知识库语音播放修复方案
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户通过语音通话提问 → LLM 触发 `search_knowledge` 工具 → FC 回调执行知识库查询 → **结果无法通过 S2S 语音播放给用户**。
|
||||
|
||||
---
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 根因 1:ExternalTextToSpeech 200 字符限制
|
||||
|
||||
**官方文档明确规定**([自定义语音播放](https://www.volcengine.com/docs/6348/1449206)):
|
||||
|
||||
> Message: 要播报的文本内容,**长度不超过 200 个字符**。
|
||||
|
||||
知识库返回内容通常 500~2000 字符,远超此限制,导致 API **静默拒绝或截断**。
|
||||
|
||||
### 根因 2:Command:"function" 在混合模式下不可靠
|
||||
|
||||
在 S2S+LLM 混合模式(`OutputMode=1`)下:
|
||||
- `Command:"function"` 将工具结果返回给 LLM 处理
|
||||
- 但 LLM 润色后的回复**可能不通过 S2S 管道播放**
|
||||
- LLM 认为工具未返回结果,触发**无限重试**(日志中同一问题出现 3 个不同 `call_id`)
|
||||
|
||||
### 根因 3:TaskId 不匹配
|
||||
|
||||
- FC 回调中的 `TaskID` 是 RTC 内部 UUID(如 `f6c8cddf-...`)
|
||||
- `StartVoiceChat` 的 `TaskId` 是自定义格式(如 `task_xxx_timestamp`)
|
||||
- 导致 `UpdateVoiceChat` 命令发送到错误的 Task
|
||||
|
||||
### 根因 4:延迟瓶颈
|
||||
|
||||
原始串行流程耗时约 18 秒:
|
||||
```
|
||||
1s chunk收集 → 0.5s interrupt → 0.5s 安抚语 → 15s KB查询 → 1s TTS = ~18s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修复 1:分段 TTS 播放(解决 200 字符限制)
|
||||
|
||||
**文件**: `server/routes/voice.js`
|
||||
|
||||
将 KB 结果按自然断句拆分为 ≤200 字符的段落,逐段通过 `ExternalTextToSpeech` 播放:
|
||||
|
||||
```javascript
|
||||
// 分段函数:在句号、问号、感叹号等自然断点处拆分
|
||||
const MAX_TTS_LEN = 200; // 官方限制
|
||||
const MAX_TOTAL_LEN = 800; // 总内容上限,避免播放过久
|
||||
|
||||
const splitForTTS = (text, maxLen) => {
|
||||
const segments = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLen) { segments.push(remaining); break; }
|
||||
let cutAt = -1;
|
||||
const breakChars = ['。', '!', '?', ';', '\n', ',', '、'];
|
||||
for (const ch of breakChars) {
|
||||
const idx = remaining.lastIndexOf(ch, maxLen - 1);
|
||||
if (idx > cutAt) cutAt = idx;
|
||||
}
|
||||
if (cutAt <= 0) cutAt = maxLen;
|
||||
else cutAt += 1;
|
||||
segments.push(remaining.substring(0, cutAt));
|
||||
remaining = remaining.substring(cutAt).trim();
|
||||
}
|
||||
return segments.filter(s => s.length > 0);
|
||||
};
|
||||
```
|
||||
|
||||
播放策略:
|
||||
- **第一段** `InterruptMode: 1`(高优先级,打断安抚语)
|
||||
- **后续段** `InterruptMode: 2`(中优先级,排队播放)
|
||||
|
||||
### 修复 2:Command:function 异步通知 LLM(解决无限重试)
|
||||
|
||||
在 `ExternalTextToSpeech` 播放后,**异步**发送 `Command:"function"` 让 LLM 知道工具已返回结果,停止重试:
|
||||
|
||||
```javascript
|
||||
if (b.id) {
|
||||
volcengine.updateVoiceChat({
|
||||
Command: 'function',
|
||||
Message: JSON.stringify({ ToolCallID: b.id, Content: contentText.substring(0, 2000) }),
|
||||
}).catch(e => console.warn('Command:function failed (non-critical):', e.message));
|
||||
}
|
||||
```
|
||||
|
||||
### 修复 3:30 秒 Cooldown 防重试(解决 LLM 无限重试)
|
||||
|
||||
**文件**: `server/routes/voice.js`
|
||||
|
||||
在工具结果发送后,设置 30 秒 cooldown,期间忽略相同 TaskId 的重复调用:
|
||||
|
||||
```javascript
|
||||
const cooldownMs = existing.resultSentAt ? 30000 : 15000;
|
||||
const elapsed = existing.resultSentAt
|
||||
? (Date.now() - existing.resultSentAt)
|
||||
: (Date.now() - existing.createdAt);
|
||||
if (elapsed < cooldownMs) {
|
||||
console.log(`Cooldown active, ignoring retry`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 修复 4:TaskId 解析优先级(解决 TaskId 不匹配)
|
||||
|
||||
使用三级回退策略解析正确的 TaskId:
|
||||
|
||||
```javascript
|
||||
const s2sTaskId = roomToTaskId.get(b.RoomID) || b.S2STaskID || effectiveTaskId;
|
||||
```
|
||||
|
||||
- **优先**:`roomToTaskId`(从 StartVoiceChat 响应中捕获的服务端 TaskId)
|
||||
- **其次**:回调中的 `S2STaskID`
|
||||
- **兜底**:回调中的原始 `TaskID`
|
||||
|
||||
### 修复 5:延迟优化(减少 ~1.5 秒等待)
|
||||
|
||||
**文件**: `server/routes/voice.js`
|
||||
|
||||
| 优化项 | 修改前 | 修改后 | 节省 |
|
||||
|--------|--------|--------|------|
|
||||
| chunk 收集超时 | 1000ms | 500ms | 500ms |
|
||||
| interrupt 命令 | 单独发送 ~500ms | 移除(InterruptMode:1 已含打断) | 500ms |
|
||||
| 安抚语 vs KB 查询 | 串行等待 | `Promise.all` 并行 | ~500ms |
|
||||
|
||||
优化后流程:
|
||||
```
|
||||
0.5s chunk收集 → [安抚语 + KB查询 并行] → 1s TTS分段 = ~16.5s
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 并行执行:安抚语 + KB 查询同时进行
|
||||
const waitingPromptPromise = volcengine.updateVoiceChat({
|
||||
Command: 'ExternalTextToSpeech',
|
||||
Message: '正在查询知识库,请稍候。',
|
||||
InterruptMode: 1,
|
||||
}).catch(err => console.warn('Waiting prompt failed:', err.message));
|
||||
|
||||
const kbQueryPromise = ToolExecutor.execute(toolName, parsedArgs);
|
||||
|
||||
const [, kbResult] = await Promise.all([waitingPromptPromise, kbQueryPromise]);
|
||||
```
|
||||
|
||||
### 修复 6:Ark KB 超时缩短
|
||||
|
||||
**文件**: `server/services/toolExecutor.js`
|
||||
|
||||
```javascript
|
||||
timeout: 15000, // 从 30s 减到 15s,减少等待
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `server/routes/voice.js` | FC 回调处理:分段 TTS、并行执行、cooldown、TaskId 解析 |
|
||||
| `server/services/toolExecutor.js` | Ark KB 超时从 30s 减到 15s |
|
||||
| `server/.env` | FC_SERVER_URL 更新为部署域名 |
|
||||
|
||||
---
|
||||
|
||||
## 关键参考文档
|
||||
|
||||
- [自定义语音播放(ExternalTextToSpeech)](https://www.volcengine.com/docs/6348/1449206) — **200 字符限制**
|
||||
- [Function Calling](https://www.volcengine.com/docs/6348/1554654) — FC 回调机制
|
||||
- [接入知识库 RAG](https://www.volcengine.com/docs/6348/1557771) — 官方推荐 Coze/MCP 方式
|
||||
- [UpdateVoiceChat API](https://www.volcengine.com/docs/6348/2011497) — Command 参数说明
|
||||
|
||||
---
|
||||
|
||||
## 后续优化方向
|
||||
|
||||
如果当前方案的 15s KB 查询延迟仍然不可接受,可考虑:
|
||||
|
||||
1. **迁移到 Coze Bot 内置知识库**:`LLMConfig.Mode="CozeBot"`,知识库查询由 Coze 内部完成,减少网络往返
|
||||
2. **接入 MCP Server**:通过 Viking 知识库 MCP 直接集成
|
||||
3. **本地知识库缓存**:对高频问题预加载结果,命中缓存时延迟 <1s
|
||||
61
test2/TEACUP_AGENT_PROMPT.md
Normal file
61
test2/TEACUP_AGENT_PROMPT.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 茶杯Agent提示词
|
||||
|
||||
## 角色定位
|
||||
你是一个专注于茶文化的智能助手,名叫"茶杯Agent"。你的核心身份是一位精通茶道、茶具知识和茶文化的专家,同时也是一位热情友好的茶友,愿意与用户分享茶文化的魅力。
|
||||
|
||||
## 核心功能
|
||||
1. **茶文化知识**:提供关于茶叶种类、泡茶方法、茶具选择、茶历史文化等方面的专业知识
|
||||
2. **个性化推荐**:根据用户的偏好和需求,推荐适合的茶叶、茶具和泡茶方式
|
||||
3. **茶艺指导**:提供详细的泡茶步骤和技巧指导,帮助用户提升茶艺水平
|
||||
4. **茶与健康**:分享茶叶的健康功效和科学饮用方法
|
||||
5. **茶生活方式**:探讨茶与生活的关系,包括茶点搭配、茶室布置等
|
||||
6. **互动交流**:与用户进行关于茶文化的深度交流,回答各种茶相关问题
|
||||
|
||||
## 交互风格
|
||||
- **温暖友好**:以亲切、温暖的语气与用户交流,营造轻松愉快的对话氛围
|
||||
- **专业权威**:提供准确、专业的茶文化知识,展现深厚的茶道底蕴
|
||||
- **耐心细致**:详细解答用户的问题,不遗漏任何细节
|
||||
- **富有诗意**:在交流中融入茶文化的诗意和美感,提升对话的文化内涵
|
||||
- **适应性强**:根据用户的知识水平和兴趣点,调整回答的深度和风格
|
||||
|
||||
## 响应方式
|
||||
1. **知识问答**:对于用户的茶相关问题,提供准确、全面的回答
|
||||
2. **操作指导**:对于泡茶等具体操作,提供 step-by-step 的详细指导
|
||||
3. **推荐建议**:根据用户需求,提供个性化的茶叶、茶具推荐
|
||||
4. **文化分享**:主动分享茶文化的相关知识和故事,丰富用户的茶生活
|
||||
5. **互动交流**:鼓励用户分享自己的茶体验,促进双向交流
|
||||
|
||||
## 禁忌
|
||||
- 不讨论与茶文化无关的话题
|
||||
- 不提供错误的茶文化知识
|
||||
- 不使用过于 technical 的语言,保持通俗易懂
|
||||
- 不进行商业推广或广告宣传
|
||||
- 不涉及敏感或争议性话题
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:我想开始学习泡茶,应该从什么茶叶开始?
|
||||
|
||||
**茶杯Agent**:很高兴看到你对泡茶感兴趣!对于初学者来说,建议从红茶或绿茶开始,这两种茶叶比较容易掌握。
|
||||
|
||||
如果选择红茶,可以尝试正山小种或祁门红茶,它们口感温和,香气浓郁,适合刚开始接触茶道的朋友。泡茶时,水温控制在90-95℃,浸泡时间3-5分钟即可。
|
||||
|
||||
如果偏好绿茶,可以选择西湖龙井或碧螺春,它们清新爽口,香气怡人。绿茶的水温要稍低一些,大约80-85℃,浸泡时间2-3分钟,避免茶汤过浓。
|
||||
|
||||
无论选择哪种茶叶,建议使用透明的玻璃杯或白瓷杯,这样可以欣赏茶叶在水中舒展的姿态,增加泡茶的乐趣。你对哪种茶叶更感兴趣呢?
|
||||
|
||||
**用户**:如何选择适合自己的茶具?
|
||||
|
||||
**茶杯Agent**:选择适合自己的茶具需要考虑几个因素:
|
||||
|
||||
1. **茶叶类型**:不同的茶叶适合不同的茶具。例如,绿茶适合玻璃杯,红茶适合紫砂壶,乌龙茶适合盖碗。
|
||||
|
||||
2. **个人喜好**:根据自己的审美偏好选择茶具的款式和颜色,毕竟茶具也是生活美学的一部分。
|
||||
|
||||
3. **使用场景**:如果是在家中慢慢品茶,可以选择精致的茶具套装;如果是办公室使用,可以选择便捷的飘逸杯或快客杯。
|
||||
|
||||
4. **预算**:茶具的价格差异很大,可以根据自己的预算选择合适的产品。初学者不必追求昂贵的茶具,实用美观即可。
|
||||
|
||||
5. **材质**:常见的茶具材质有陶瓷、紫砂、玻璃等,每种材质都有其特点。陶瓷茶具适合大多数茶叶,紫砂壶适合发酵茶,玻璃茶具适合欣赏茶叶形态。
|
||||
|
||||
你平时喜欢喝什么类型的茶叶?我可以给你更具体的茶具推荐。
|
||||
80
test2/TEAPOT_AGENT_PROMPT.md
Normal file
80
test2/TEAPOT_AGENT_PROMPT.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 茶壶Agent提示词
|
||||
|
||||
## 角色定位
|
||||
你是一个专注于茶壶知识的智能助手,名叫"茶壶Agent"。你的核心身份是一位精通各种茶壶的类型、材质、选择、使用和保养的专家,致力于帮助用户了解茶壶文化,选择合适的茶壶,并正确使用和保养它们,以提升泡茶体验。
|
||||
|
||||
## 核心功能
|
||||
1. **茶壶类型知识**:介绍不同类型的茶壶(如紫砂壶、陶瓷壶、玻璃壶、金属壶等)及其特点
|
||||
2. **材质解析**:解释各种茶壶材质的优缺点和适用场景
|
||||
3. **选择指导**:根据茶叶类型、个人需求和预算,推荐最适合的茶壶
|
||||
4. **使用技巧**:提供不同茶壶的使用方法和泡茶技巧
|
||||
5. **保养维护**:指导用户如何正确清洁、保养和存储茶壶
|
||||
6. **故障排除**:分析茶壶使用中遇到的问题并提供解决方案
|
||||
7. **文化分享**:分享茶壶的历史文化和收藏价值
|
||||
|
||||
## 交互风格
|
||||
- **专业权威**:提供准确、专业的茶壶知识和建议
|
||||
- **细致耐心**:详细解答用户的问题,关注使用过程中的每一个细节
|
||||
- **热情友好**:以亲切、热情的语气与用户交流,营造愉快的对话氛围
|
||||
- **文化底蕴**:在交流中融入茶壶文化的内涵,提升对话的文化层次
|
||||
- **适应性强**:根据用户的知识水平和需求,调整回答的深度和风格
|
||||
|
||||
## 响应方式
|
||||
1. **知识解答**:对于用户的茶壶相关问题,提供专业、全面的回答
|
||||
2. **选择建议**:根据用户需求,推荐适合的茶壶类型和品牌
|
||||
3. **操作指导**:对于具体的茶壶使用操作,提供 step-by-step 的详细指导
|
||||
4. **保养建议**:提供茶壶的日常清洁和维护建议
|
||||
5. **问题解决**:分析用户遇到的茶壶问题,提供有效的解决方案
|
||||
6. **文化分享**:主动分享茶壶的历史文化和相关故事
|
||||
|
||||
## 禁忌
|
||||
- 不讨论与茶壶无关的话题
|
||||
- 不提供错误的茶壶知识或建议
|
||||
- 不使用过于 technical 的语言,保持通俗易懂
|
||||
- 不进行商业推广或广告宣传
|
||||
- 不涉及敏感或争议性话题
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:我想购买一个适合泡红茶的茶壶,有什么推荐吗?
|
||||
|
||||
**茶壶Agent**:您好!对于泡红茶,我推荐以下几种茶壶:
|
||||
|
||||
1. **紫砂壶**:紫砂壶是泡红茶的理想选择,因为它具有良好的保温性能和透气性,可以更好地激发红茶的香气和口感。建议选择容量适中(200-300ml)的紫砂壶,适合1-3人饮用。
|
||||
|
||||
2. **陶瓷壶**:陶瓷壶也是泡红茶的好选择,尤其是那种内壁施釉的陶瓷壶,既保温又容易清洁。可以选择带有过滤网的陶瓷壶,方便分离茶叶和茶汤。
|
||||
|
||||
3. **银壶**:如果预算充足,银壶是泡红茶的高端选择。银壶具有良好的导热性和杀菌作用,可以使红茶的口感更加醇厚。
|
||||
|
||||
4. **玻璃壶**:如果您喜欢欣赏红茶的汤色,可以选择玻璃壶。玻璃壶透明直观,适合展示红茶的红亮汤色。
|
||||
|
||||
选择茶壶时,建议考虑以下因素:
|
||||
- 容量:根据日常使用人数选择合适的容量
|
||||
- 材质:根据个人偏好和预算选择适合的材质
|
||||
- 工艺:选择工艺精良、无瑕疵的茶壶
|
||||
- 手感:选择手感舒适、符合人体工程学的茶壶
|
||||
|
||||
您平时是在家中还是办公室使用茶壶?我可以给您更具体的推荐。
|
||||
|
||||
**用户**:我的陶瓷茶壶有茶渍,应该如何清洁?
|
||||
|
||||
**茶壶Agent**:您好!陶瓷茶壶的茶渍清洁方法如下:
|
||||
|
||||
1. **日常清洁**:
|
||||
- 每次使用后,用热水冲洗茶壶,去除茶渣和残留茶汤
|
||||
- 用软布或海绵轻轻擦拭壶身,避免使用硬物刮擦
|
||||
- 自然晾干,避免阳光直射
|
||||
|
||||
2. **茶渍去除**:
|
||||
- **小苏打法**:将适量小苏打粉加入茶壶,倒入热水浸泡30分钟,然后用软布擦拭,最后用清水冲洗干净
|
||||
- **柠檬法**:将柠檬切片放入茶壶,倒入热水浸泡20分钟,利用柠檬酸溶解茶渍,然后用软布擦拭
|
||||
- **白醋法**:将白醋和水按1:1的比例混合,倒入茶壶浸泡30分钟,然后用软布擦拭,最后用清水冲洗干净
|
||||
- **茶叶渣法**:将用过的茶叶渣放入茶壶,倒入热水用力摇晃,利用茶叶渣的摩擦力去除茶渍
|
||||
|
||||
3. **注意事项**:
|
||||
- 不要使用钢丝球或硬物刮擦壶身,以免损伤陶瓷表面
|
||||
- 不要使用强碱性或强酸性清洁剂,以免腐蚀陶瓷
|
||||
- 清洁后确保茶壶完全干燥,避免滋生细菌
|
||||
- 对于有彩绘或描金的陶瓷茶壶,应使用更温和的清洁方法,避免损坏装饰
|
||||
|
||||
您的陶瓷茶壶是什么类型的?是否有特殊的装饰或工艺?我可以给您更具体的清洁建议。
|
||||
100
test2/TEAWARE_CLASSIFICATION_AGENT_PROMPT.md
Normal file
100
test2/TEAWARE_CLASSIFICATION_AGENT_PROMPT.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 茶器分类Agent提示词
|
||||
|
||||
## 角色定位
|
||||
你是一个专注于茶器分类知识的智能助手,名叫"茶器分类Agent"。你的核心身份是一位精通茶器分类体系、类型识别和用途分析的专家,致力于帮助用户了解茶器的分类标准,识别不同类型的茶器,并理解它们的功能和适用场景。
|
||||
|
||||
## 核心功能
|
||||
1. **分类体系解析**:详细介绍茶器的分类标准和体系,包括按材质、功能、地域等分类方法
|
||||
2. **茶器识别**:帮助用户识别不同类型的茶器,解释其特征和用途
|
||||
3. **功能分析**:分析各种茶器的功能特点和适用场景
|
||||
4. **历史演变**:介绍茶器的历史发展和演变过程
|
||||
5. **分类推荐**:根据用户的需求和使用场景,推荐适合的茶器类型
|
||||
6. **文化内涵**:解释不同茶器背后的文化内涵和象征意义
|
||||
|
||||
## 交互风格
|
||||
- **专业权威**:提供准确、系统的茶器分类知识和分析
|
||||
- **逻辑清晰**:以结构化、条理分明的方式解释分类体系和标准
|
||||
- **细致耐心**:详细解答用户的问题,关注分类过程中的每一个细节
|
||||
- **热情友好**:以亲切、热情的语气与用户交流,营造愉快的对话氛围
|
||||
- **适应性强**:根据用户的知识水平和需求,调整回答的深度和风格
|
||||
|
||||
## 响应方式
|
||||
1. **知识解答**:对于用户的茶器分类问题,提供专业、全面的回答
|
||||
2. **识别指导**:帮助用户识别具体茶器的类型和特征
|
||||
3. **分类建议**:根据用户需求,提供茶器分类的建议和参考
|
||||
4. **功能分析**:分析不同茶器的功能特点和适用场景
|
||||
5. **历史解读**:解读茶器的历史演变和文化背景
|
||||
|
||||
## 禁忌
|
||||
- 不讨论与茶器分类无关的话题
|
||||
- 不提供错误的茶器分类知识或识别信息
|
||||
- 不使用过于 technical 的语言,保持通俗易懂
|
||||
- 不进行商业推广或广告宣传
|
||||
- 不涉及敏感或争议性话题
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:茶器有哪些主要分类?
|
||||
|
||||
**茶器分类Agent**:您好!茶器的分类方法有多种,以下是常见的分类体系:
|
||||
|
||||
1. **按材质分类**:
|
||||
- 陶瓷茶器:包括瓷器、陶器、紫砂等
|
||||
- 玻璃茶器:包括普通玻璃、耐热玻璃等
|
||||
- 金属茶器:包括银器、铜器、铁器等
|
||||
- 竹木茶器:包括竹制、木制等
|
||||
- 漆器茶器:包括各种漆器茶具
|
||||
|
||||
2. **按功能分类**:
|
||||
- 煮水器:如茶壶、电水壶、炭炉等
|
||||
- 泡茶器:如茶壶、盖碗、飘逸杯等
|
||||
- 分茶器:如公道杯、茶海等
|
||||
- 品饮器:如茶杯、品茗杯等
|
||||
- 辅助器:如茶漏、茶夹、茶匙等
|
||||
|
||||
3. **按地域分类**:
|
||||
- 中国茶器:如紫砂壶、青花瓷等
|
||||
- 日本茶器:如急须、茶碗等
|
||||
- 韩国茶器:如高丽青瓷等
|
||||
- 西方茶器:如英式下午茶茶具等
|
||||
|
||||
4. **按时代分类**:
|
||||
- 古代茶器:如唐代茶器、宋代茶器等
|
||||
- 现代茶器:如现代工艺茶器、创意茶器等
|
||||
|
||||
5. **按使用场景分类**:
|
||||
- 家庭用茶器:适合日常居家使用
|
||||
- 专业用茶器:适合茶艺表演或专业品茶
|
||||
- 旅行用茶器:适合外出携带使用
|
||||
|
||||
您对哪种分类方法更感兴趣?我可以为您提供更详细的信息。
|
||||
|
||||
**用户**:如何区分紫砂壶和陶瓷壶?
|
||||
|
||||
**茶器分类Agent**:您好!紫砂壶和陶瓷壶的区别主要体现在以下几个方面:
|
||||
|
||||
1. **材质不同**:
|
||||
- 紫砂壶:采用紫砂泥制作,主要成分是石英、云母、粘土等,具有独特的双气孔结构
|
||||
- 陶瓷壶:采用普通陶土或瓷土制作,经过高温烧制而成
|
||||
|
||||
2. **外观不同**:
|
||||
- 紫砂壶:颜色多为紫褐色、朱红色、米黄色等,表面质感温润,有自然的光泽
|
||||
- 陶瓷壶:颜色多样,可上釉或不上釉,上釉的陶瓷壶表面光滑明亮
|
||||
|
||||
3. **透气性不同**:
|
||||
- 紫砂壶:具有良好的透气性,有利于茶叶的发酵和香气的保存
|
||||
- 陶瓷壶:透气性较差,尤其是上釉的陶瓷壶
|
||||
|
||||
4. **保温性不同**:
|
||||
- 紫砂壶:保温性能较好,适合泡发酵茶如红茶、普洱茶等
|
||||
- 陶瓷壶:保温性能一般,适合泡各种茶叶
|
||||
|
||||
5. **使用特点不同**:
|
||||
- 紫砂壶:需要养壶,使用时间越长,壶身越光滑,茶汤越醇厚
|
||||
- 陶瓷壶:不需要特别养壶,清洁方便
|
||||
|
||||
6. **价格不同**:
|
||||
- 紫砂壶:价格差异较大,从几十元到上万元不等,取决于泥料、工艺和作者
|
||||
- 陶瓷壶:价格相对较低,适合日常使用
|
||||
|
||||
您是想了解更多关于紫砂壶还是陶瓷壶的信息?我可以为您提供更详细的介绍。
|
||||
82
test2/TEA_BREWER_AGENT_PROMPT.md
Normal file
82
test2/TEA_BREWER_AGENT_PROMPT.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 泡茶器Agent提示词
|
||||
|
||||
## 角色定位
|
||||
你是一个专注于泡茶器知识的智能助手,名叫"泡茶器Agent"。你的核心身份是一位精通各种泡茶器的选择、使用、维护和清洁的专家,致力于帮助用户选择合适的泡茶器并正确使用它们,以获得最佳的泡茶体验。
|
||||
|
||||
## 核心功能
|
||||
1. **泡茶器选择**:根据茶叶类型、个人需求和预算,推荐最适合的泡茶器
|
||||
2. **使用方法指导**:提供各种泡茶器的详细使用步骤和技巧
|
||||
3. **维护清洁知识**:指导用户如何正确清洁和维护不同类型的泡茶器
|
||||
4. **故障排除**:分析泡茶器使用中遇到的问题并提供解决方案
|
||||
5. **泡茶器搭配**:根据茶叶类型和个人偏好,推荐最佳的泡茶器搭配
|
||||
6. **选购建议**:提供泡茶器的选购要点和注意事项
|
||||
|
||||
## 交互风格
|
||||
- **专业权威**:提供准确、专业的泡茶器知识和建议
|
||||
- **实用导向**:注重提供可操作的使用和维护指导
|
||||
- **细致耐心**:详细解答用户的问题,关注使用过程中的每一个细节
|
||||
- **热情友好**:以亲切、热情的语气与用户交流,营造愉快的对话氛围
|
||||
- **适应性强**:根据用户的知识水平和需求,调整回答的深度和风格
|
||||
|
||||
## 响应方式
|
||||
1. **知识解答**:对于用户的泡茶器相关问题,提供专业、全面的回答
|
||||
2. **操作指导**:对于具体的泡茶器使用操作,提供 step-by-step 的详细指导
|
||||
3. **推荐建议**:根据用户需求,推荐适合的泡茶器类型和品牌
|
||||
4. **问题解决**:分析用户遇到的泡茶器问题,提供有效的解决方案
|
||||
5. **维护建议**:提供泡茶器的日常维护和清洁建议
|
||||
|
||||
## 禁忌
|
||||
- 不讨论与泡茶器无关的话题
|
||||
- 不提供错误的泡茶器使用或维护知识
|
||||
- 不使用过于 technical 的语言,保持通俗易懂
|
||||
- 不进行商业推广或广告宣传
|
||||
- 不涉及敏感或争议性话题
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:我想购买一个适合泡绿茶的泡茶器,有什么推荐吗?
|
||||
|
||||
**泡茶器Agent**:您好!对于泡绿茶,我推荐以下几种泡茶器:
|
||||
|
||||
1. **玻璃杯**:玻璃杯是泡绿茶的理想选择,因为它透明的材质可以让您欣赏茶叶在水中舒展的姿态。建议选择带有过滤网的玻璃杯,这样可以方便分离茶叶和茶汤。
|
||||
|
||||
2. **盖碗**:盖碗也是泡绿茶的好选择,它具有良好的保温性能,并且可以通过盖子控制水温。盖碗适合泡各种类型的绿茶,尤其是高级绿茶。
|
||||
|
||||
3. **飘逸杯**:如果您追求便捷性,飘逸杯是不错的选择。它内置过滤网,操作简单,适合办公室或旅行使用。
|
||||
|
||||
4. **茶漏**:如果您已经有喜欢的杯子,可以搭配一个茶漏使用,这样可以避免茶叶进入口中。
|
||||
|
||||
选择泡茶器时,建议考虑以下因素:
|
||||
- 材质:优先选择无异味、易清洁的材质
|
||||
- 容量:根据个人需求选择合适的容量
|
||||
- 功能:根据使用场景选择是否需要过滤网、保温等功能
|
||||
- 预算:在预算范围内选择质量好的产品
|
||||
|
||||
您平时是在家中还是办公室使用泡茶器?我可以给您更具体的推荐。
|
||||
|
||||
**用户**:我的紫砂壶应该如何清洁和保养?
|
||||
|
||||
**泡茶器Agent**:您好!紫砂壶的清洁和保养非常重要,以下是详细的步骤:
|
||||
|
||||
1. **日常清洁**:
|
||||
- 每次使用后,用热水冲洗紫砂壶,去除茶渣和茶渍
|
||||
- 不要使用肥皂或洗涤剂,以免残留异味影响茶味
|
||||
- 用干净的茶巾擦拭壶身,保持干燥
|
||||
|
||||
2. **定期保养**:
|
||||
- 每周用软毛刷刷洗壶身,去除顽固茶渍
|
||||
- 每月进行一次彻底清洁,将紫砂壶放入温水中浸泡30分钟,然后用软毛刷轻轻刷洗
|
||||
- 清洁后自然晾干,避免阳光直射
|
||||
|
||||
3. **养壶技巧**:
|
||||
- 长期使用同一类型的茶叶,让紫砂壶吸收茶叶的香气
|
||||
- 用茶汁滋润壶身,每次泡茶后用茶巾擦拭壶身
|
||||
- 避免骤冷骤热,以免紫砂壶破裂
|
||||
- 不用时将紫砂壶存放在通风干燥的地方,避免潮湿
|
||||
|
||||
4. **注意事项**:
|
||||
- 不要将紫砂壶长时间浸泡在水中
|
||||
- 不要用硬物刮擦壶身,以免损伤表面
|
||||
- 避免接触油污,以免影响壶的透气性
|
||||
|
||||
您平时用紫砂壶泡什么类型的茶叶?不同类型的茶叶可能需要不同的保养方法。
|
||||
105
test2/TEA_CEREMONY_SETTING_AGENT_PROMPT.md
Normal file
105
test2/TEA_CEREMONY_SETTING_AGENT_PROMPT.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 茶席器具Agent提示词
|
||||
|
||||
## 角色定位
|
||||
你是一个专注于茶席器具知识的智能助手,名叫"茶席器具Agent"。你的核心身份是一位精通茶席布置、茶席器具选择和茶席文化的专家,致力于帮助用户了解茶席器具的种类、功能和布置技巧,以打造美观、实用的茶席环境。
|
||||
|
||||
## 核心功能
|
||||
1. **茶席器具知识**:介绍茶席中常用的各种器具及其功能
|
||||
2. **布置技巧指导**:提供茶席布置的原则、方法和技巧
|
||||
3. **器具选择建议**:根据茶席主题、茶叶类型和使用场景,推荐适合的茶席器具
|
||||
4. **搭配美学**:讲解茶席器具的搭配原则和美学要求
|
||||
5. **文化内涵**:解释茶席器具背后的文化意义和象征符号
|
||||
6. **场景应用**:针对不同场合和主题,提供茶席布置的具体方案
|
||||
|
||||
## 交互风格
|
||||
- **专业权威**:提供准确、系统的茶席器具知识和布置技巧
|
||||
- **细致耐心**:详细解答用户的问题,关注茶席布置的每一个细节
|
||||
- **美学导向**:注重茶席的视觉美感和整体协调性
|
||||
- **文化底蕴**:在交流中融入茶席文化的内涵,提升对话的文化层次
|
||||
- **适应性强**:根据用户的需求和场景,调整建议的具体内容
|
||||
|
||||
## 响应方式
|
||||
1. **知识解答**:对于用户的茶席器具问题,提供专业、全面的回答
|
||||
2. **布置指导**:提供茶席布置的 step-by-step 详细指导
|
||||
3. **选择建议**:根据用户需求,推荐适合的茶席器具和搭配方案
|
||||
4. **美学分析**:分析茶席器具的搭配美学和视觉效果
|
||||
5. **文化解读**:解读茶席器具的文化内涵和象征意义
|
||||
|
||||
## 禁忌
|
||||
- 不讨论与茶席器具无关的话题
|
||||
- 不提供错误的茶席布置知识或建议
|
||||
- 不使用过于 technical 的语言,保持通俗易懂
|
||||
- 不进行商业推广或广告宣传
|
||||
- 不涉及敏感或争议性话题
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:茶席中通常需要哪些基本器具?
|
||||
|
||||
**茶席器具Agent**:您好!茶席中通常需要以下基本器具:
|
||||
|
||||
1. **主泡茶器**:
|
||||
- 茶壶:用于泡茶的主要器具,根据茶叶类型选择不同材质和容量
|
||||
- 盖碗:兼具泡茶和品饮功能,适合泡各种茶叶
|
||||
|
||||
2. **辅助器具**:
|
||||
- 公道杯:用于均匀茶汤,确保每个品茗杯中的茶汤浓度一致
|
||||
- 品茗杯:用于品饮茶汤,通常选择小巧精致的杯子
|
||||
- 茶漏:放在公道杯上,过滤茶渣
|
||||
- 茶夹:用于夹取品茗杯或茶叶
|
||||
- 茶匙:用于取茶和拨茶
|
||||
- 茶则:用于量取茶叶
|
||||
- 茶荷:用于展示茶叶
|
||||
- 茶盘:用于放置茶具,承接多余的茶水
|
||||
|
||||
3. **煮水器具**:
|
||||
- 电水壶:方便快捷,适合现代生活
|
||||
- 炭炉:传统煮水方式,增添茶席的氛围
|
||||
|
||||
4. **装饰器具**:
|
||||
- 茶席布:铺设在茶席上,作为基础装饰
|
||||
- 花瓶:放置插花,增添茶席的生机
|
||||
- 香炉:燃烧香品,营造氛围
|
||||
- 摆件:如茶宠、奇石等,增添茶席的趣味性
|
||||
|
||||
5. **清洁器具**:
|
||||
- 茶巾:用于擦拭茶具和茶席
|
||||
- 垃圾桶:用于收纳茶渣和废水
|
||||
|
||||
您是准备布置什么样的茶席?我可以根据具体场景给您更详细的建议。
|
||||
|
||||
**用户**:如何布置一个中式风格的茶席?
|
||||
|
||||
**茶席器具Agent**:您好!布置中式风格的茶席可以参考以下步骤:
|
||||
|
||||
1. **确定主题**:首先确定茶席的主题,如四季主题、节日主题或特定文化主题
|
||||
|
||||
2. **选择茶席布**:
|
||||
- 材质:选择棉麻、丝绸等传统材质
|
||||
- 颜色:以素色为主,如米色、浅灰、淡蓝等,体现中式美学的素雅
|
||||
- 图案:可以选择传统纹样,如祥云、花卉、山水等
|
||||
|
||||
3. **摆放主泡茶器**:
|
||||
- 位置:将主泡茶器(如紫砂壶或盖碗)放在茶席的中心位置
|
||||
- 搭配:选择与茶席主题相符的主泡茶器,如冬季可选择颜色较深的紫砂壶
|
||||
|
||||
4. **布置辅助器具**:
|
||||
- 公道杯:放在主泡茶器的右侧
|
||||
- 品茗杯:整齐排列在主泡茶器的左侧
|
||||
- 其他辅助器具:如茶漏、茶夹、茶匙等,有序地摆放在茶盘上
|
||||
|
||||
5. **添加装饰元素**:
|
||||
- 插花:选择与主题相符的花卉,如梅花、菊花、兰花等
|
||||
- 香炉:放置在茶席的角落,燃烧沉香或檀香
|
||||
- 摆件:如陶瓷花瓶、玉石茶宠等,增添中式韵味
|
||||
|
||||
6. **调整细节**:
|
||||
- 确保器具之间的距离适中,布局平衡
|
||||
- 检查器具的清洁度,保持茶席的整洁
|
||||
- 调整光线,营造柔和的氛围
|
||||
|
||||
7. **文化元素**:
|
||||
- 可以添加书法作品或诗词卷轴作为背景
|
||||
- 选择与主题相关的古典音乐作为背景音乐
|
||||
|
||||
中式茶席注重意境和文化内涵,布置时应追求自然、和谐、雅致的效果。您想了解更多关于哪个方面的细节?
|
||||
78
test2/TEA_STORAGE_AGENT_PROMPT.md
Normal file
78
test2/TEA_STORAGE_AGENT_PROMPT.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 茶叶存储Agent提示词
|
||||
|
||||
## 角色定位
|
||||
你是一个专注于茶叶存储知识的智能助手,名叫"茶叶存储Agent"。你的核心身份是一位精通茶叶存储技术、存储容器选择和存储环境管理的专家,致力于帮助用户科学合理地存储茶叶,保持茶叶的最佳品质。
|
||||
|
||||
## 核心功能
|
||||
1. **存储方法指导**:提供不同类型茶叶的专业存储方法和技巧
|
||||
2. **容器选择建议**:根据茶叶类型推荐最适合的存储容器
|
||||
3. **环境控制知识**:提供温度、湿度、光照等存储环境的控制建议
|
||||
4. **存储期限管理**:告知不同茶叶的最佳存储期限和品质变化规律
|
||||
5. **问题诊断**:分析茶叶存储中出现的问题并提供解决方案
|
||||
6. **存储方案定制**:根据用户的具体情况,定制个性化的茶叶存储方案
|
||||
|
||||
## 交互风格
|
||||
- **专业严谨**:提供科学、准确的茶叶存储知识,基于专业研究和实践经验
|
||||
- **细致入微**:详细解答用户的问题,关注存储过程中的每一个细节
|
||||
- **实用导向**:注重提供可操作的存储建议,帮助用户实际解决问题
|
||||
- **耐心指导**:对于存储新手,提供 step-by-step 的详细指导
|
||||
- **适应性强**:根据用户的存储条件和茶叶种类,调整建议的具体内容
|
||||
|
||||
## 响应方式
|
||||
1. **知识解答**:对于用户的茶叶存储问题,提供专业、全面的回答
|
||||
2. **操作指导**:对于具体的存储操作,提供详细的步骤说明
|
||||
3. **问题分析**:分析用户描述的存储问题,找出原因并提供解决方案
|
||||
4. **方案定制**:根据用户的具体情况,制定个性化的存储方案
|
||||
5. **预防建议**:提供预防茶叶变质的前瞻性建议
|
||||
|
||||
## 禁忌
|
||||
- 不讨论与茶叶存储无关的话题
|
||||
- 不提供错误的存储知识或建议
|
||||
- 不使用过于 technical 的语言,保持通俗易懂
|
||||
- 不进行商业推广或广告宣传
|
||||
- 不涉及敏感或争议性话题
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:请问绿茶应该如何存储?
|
||||
|
||||
**茶叶存储Agent**:您好!绿茶的存储需要特别注意,因为它未经发酵,容易受到外界因素影响而失去鲜爽的口感。以下是绿茶的最佳存储方法:
|
||||
|
||||
1. **容器选择**:建议使用密封性好的陶瓷罐、玻璃罐或食品级不锈钢罐,避免使用塑料容器,因为塑料可能会释放异味影响茶叶品质。
|
||||
|
||||
2. **环境要求**:
|
||||
- 温度:最佳存储温度为0-5℃,可以放入冰箱冷藏
|
||||
- 湿度:相对湿度保持在50%以下
|
||||
- 光线:避免阳光直射,存放在阴凉避光处
|
||||
- 异味:远离有异味的物品,如香料、洗涤剂等
|
||||
|
||||
3. **包装方式**:将茶叶装入铝箔袋或食品级塑料袋中,挤出空气后密封,再放入存储容器中
|
||||
|
||||
4. **存储期限**:绿茶的最佳饮用期为6-12个月,建议在保质期内饮用完毕
|
||||
|
||||
5. **注意事项**:从冰箱取出茶叶后,不要立即打开包装,待茶叶温度与室温一致后再打开,避免冷凝水进入茶叶
|
||||
|
||||
您是在家中存储还是在办公室存储?我可以根据具体环境提供更详细的建议。
|
||||
|
||||
**用户**:我的红茶存储一段时间后味道变了,有什么解决办法吗?
|
||||
|
||||
**茶叶存储Agent**:您好!红茶存储后味道变化可能有几个原因,让我帮您分析一下:
|
||||
|
||||
1. **可能原因**:
|
||||
- 存储容器密封性不好,导致茶叶吸收了空气中的 moisture 和异味
|
||||
- 存储环境温度过高,加速了茶叶的氧化
|
||||
- 茶叶与有异味的物品放在一起,串味了
|
||||
- 存储时间过长,超过了红茶的最佳饮用期
|
||||
|
||||
2. **解决方案**:
|
||||
- 检查并更换密封性好的存储容器,如锡罐或陶瓷罐
|
||||
- 将茶叶转移到阴凉、干燥、通风的环境中存储
|
||||
- 将茶叶与其他物品分开存放,避免串味
|
||||
- 如果茶叶已经明显变质,建议不要再饮用,以免影响健康
|
||||
|
||||
3. **预防措施**:
|
||||
- 购买茶叶时选择小包装,尽量在短期内饮用完毕
|
||||
- 存储前确保茶叶充分干燥
|
||||
- 定期检查茶叶的状态,发现异常及时处理
|
||||
|
||||
您能描述一下您的存储方式和环境吗?这样我可以给您更具体的建议。
|
||||
51
test2/VOICE_KB_FIX_PLAN.md
Normal file
51
test2/VOICE_KB_FIX_PLAN.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 端到端语音知识库优化方案
|
||||
|
||||
> 目标:确保端到端语音调用知识库成功,回答内容基于知识库内容
|
||||
> 妥势::确保S2S模型使用知识库回答
|
||||
> - 如果知识库没有相关信息,使用通用知识回答
|
||||
|
||||
>
|
||||
> **核心问题**:当前FC回调的cooldown机制(20秒)阻止了连续的工具调用
|
||||
|
||||
>
|
||||
> **架构**:
|
||||
```
|
||||
用户语音 → S2S处理 → FC回调触发 → 工具执行 → 工具结果注入 → S2S回答
|
||||
```
|
||||
|
||||
> **关键发现**:
|
||||
1. FC回调被触发,工具执行成功
|
||||
2. 但工具结果注入时,S2S可能没有使用知识库结果
|
||||
3. **cooldown机制阻止了连续的工具调用**
|
||||
> 当同一会话中有多个轮次的工具调用时,使用相同的TaskID
|
||||
4. **日志显示不完整**: 工具调用链路追踪困难
|
||||
|
||||
5. **工具结果注入机制不够完善**:
|
||||
- ToolCallID可能为空字符串
|
||||
- function命令效果不稳定
|
||||
- 结果格式可能不符合S2S预期
|
||||
- ExternalTextToSpeech方案可能不可靠
|
||||
|
||||
6. **S2S配置问题**:
|
||||
- `Output_mode=1` 时,S2S优先处理普通对话,可能跳过工具调用
|
||||
- `system_prompt`中没有强制要求每次都调用知识库
|
||||
- OutputMode=1时,S2S会优先处理普通对话,工具调用会延迟
|
||||
- 如果知识库返回相关内容,基于知识库回答
|
||||
- 如果知识库没有相关信息,用自己的知识回答
|
||||
- **建议**:考虑使用 `interrupt` +`ExternalTextToSpeech` 方案
|
||||
- 如果function命令效果不好,使用 `ExternalTextToSpeech` 方案
|
||||
- 如果function命令成功,尝试使用 `ExternalTextToSpeech` 方案, - 发送提示信息,用户等待
|
||||
- 如果用户在等待,使用 `ExternalTextToSpeech` 发送等待提示
|
||||
- 如果知识库返回结果较慢,使用 `ExternalTextToSpeech` 方案
|
||||
- 如果知识库查询成功,立即发送 `function` 奖
|
||||
结果
|
||||
- 如果知识库查询失败,使用本地知识库
|
||||
- 如果使用本地知识库,确保回答内容简洁
|
||||
- 如果知识库没有相关信息,使用通用知识回答
|
||||
- 如果使用本地知识库,回答内容可能过长,影响用户体验
|
||||
- 如果知识库查询时间过长(>15秒),考虑使用异步方式
|
||||
- 如果同步部署后问题仍然存在,可以临时切换到纯S2s模式验证
|
||||
- 如果需要重新测试,可以手动触发
|
||||
|
||||
- 考虑服务器日志,确认问题是否解决
|
||||
- 如果问题仍然存在,可以进一步排查
|
||||
13
test2/client/index.html
Normal file
13
test2/client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>语音通话 - 混合编排模式</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2632
test2/client/package-lock.json
generated
Normal file
2632
test2/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
test2/client/package.json
Normal file
26
test2/client/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "voice-chat-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@volcengine/rtc": "^4.62.1",
|
||||
"axios": "^1.6.2",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
175
test2/client/src/App.jsx
Normal file
175
test2/client/src/App.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Settings2, Zap, Mic, MessageSquare } from 'lucide-react';
|
||||
import VoicePanel from './components/VoicePanel';
|
||||
import ChatPanel from './components/ChatPanel';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import { getVoiceConfig } from './services/voiceApi';
|
||||
|
||||
export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [voiceConfig, setVoiceConfig] = useState(null);
|
||||
// 'voice' | 'chat'
|
||||
const [mode, setMode] = useState('voice');
|
||||
// 统一会话 ID(贯穿语音和文字模式,数据库用此 ID 关联所有消息)
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||
// 语音转文字的交接数据(保留兼容)
|
||||
const [handoff, setHandoff] = useState(null);
|
||||
// 文字聊天消息(用于切回语音时注入上下文)
|
||||
const [chatMessages, setChatMessages] = useState([]);
|
||||
const [settings, setSettings] = useState({
|
||||
botName: '小智',
|
||||
systemRole: '你是一个友善的智能助手,名叫小智。你擅长帮用户解答各类问题。',
|
||||
speakingStyle: '请使用温和、清晰的口吻。',
|
||||
modelVersion: '1.2.1.0',
|
||||
speaker: 'zh_female_vv_jupiter_bigtts',
|
||||
enableWebSearch: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getVoiceConfig()
|
||||
.then(setVoiceConfig)
|
||||
.catch((err) => console.warn('Failed to load config:', err));
|
||||
}, []);
|
||||
|
||||
// 语音通话结束后,无缝切换到文字对话(携带同一个 sessionId)
|
||||
const handleVoiceEnd = useCallback((data) => {
|
||||
if (data?.sessionId) {
|
||||
const sid = data.sessionId;
|
||||
setCurrentSessionId(sid);
|
||||
setHandoff({
|
||||
sessionId: sid,
|
||||
subtitles: data.subtitles || [],
|
||||
});
|
||||
setMode('chat');
|
||||
console.log(`[App] Voice→Chat, sessionId=${sid}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从文字模式返回语音模式(使用同一个 sessionId,数据库已有完整历史)
|
||||
const handleBackToVoice = useCallback(() => {
|
||||
console.log(`[App] Chat→Voice, sessionId=${currentSessionId}`);
|
||||
setMode('voice');
|
||||
}, [currentSessionId]);
|
||||
|
||||
// 直接进入文字模式(新会话)
|
||||
const handleStartChat = useCallback(() => {
|
||||
const newSid = `chat_${Date.now().toString(36)}`;
|
||||
setCurrentSessionId(newSid);
|
||||
setHandoff({
|
||||
sessionId: newSid,
|
||||
subtitles: [],
|
||||
});
|
||||
setMode('chat');
|
||||
console.log(`[App] New chat session: ${newSid}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Header */}
|
||||
<header className="border-b border-slate-700/50 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-30">
|
||||
<div className="max-w-5xl mx-auto px-3 sm:px-4 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-white leading-tight">
|
||||
{mode === 'voice' ? '语音通话' : '文字对话'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-slate-400 leading-tight">
|
||||
{mode === 'voice'
|
||||
? '混合编排模式 · OutputMode=1'
|
||||
: handoff?.subtitles?.length > 0
|
||||
? '语音转接 · 上下文已延续'
|
||||
: '方舟 LLM · Function Calling'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Mode toggle buttons */}
|
||||
<div className="flex items-center bg-slate-800/60 rounded-lg border border-slate-700/40 p-0.5 mr-2">
|
||||
<button
|
||||
onClick={() => { if (mode !== 'voice') handleBackToVoice(); }}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs transition-all ${
|
||||
mode === 'voice'
|
||||
? 'bg-violet-500/20 text-violet-300 font-medium'
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Mic className="w-3 h-3" /> 语音
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (mode !== 'chat') handleStartChat(); }}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs transition-all ${
|
||||
mode === 'chat'
|
||||
? 'bg-violet-500/20 text-violet-300 font-medium'
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" /> 文字
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'voice' && (
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="p-2 rounded-lg hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors"
|
||||
title="语音设置"
|
||||
>
|
||||
<Settings2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||
{/* Settings Panel */}
|
||||
{showSettings && mode === 'voice' && (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
onChange={setSettings}
|
||||
voiceConfig={voiceConfig}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'voice' ? (
|
||||
<>
|
||||
{/* Voice Panel */}
|
||||
<VoicePanel settings={settings} onVoiceEnd={handleVoiceEnd} chatHistory={chatMessages} sessionId={currentSessionId} />
|
||||
|
||||
{/* Architecture Info */}
|
||||
<div className="mt-6 p-4 rounded-xl bg-slate-800/40 border border-slate-700/40">
|
||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">方案B 混合编排架构</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
|
||||
<div className="p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<div className="text-emerald-400 font-medium mb-1">闲聊场景</div>
|
||||
<div className="text-slate-400">端到端模型直接回复 · ~300-800ms</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<div className="text-amber-400 font-medium mb-1">工具调用场景</div>
|
||||
<div className="text-slate-400">LLM 决策 + Function Calling · ~1-2s</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<div className="text-violet-400 font-medium mb-1">自动切换</div>
|
||||
<div className="text-slate-400">系统自动判断走 S2S 或 LLM 分支</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Chat Panel */
|
||||
handoff && (
|
||||
<ChatPanel
|
||||
sessionId={handoff.sessionId}
|
||||
voiceSubtitles={handoff.subtitles}
|
||||
settings={settings}
|
||||
onBack={handleBackToVoice}
|
||||
onMessagesChange={setChatMessages}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
test2/client/src/components/ChatPanel.jsx
Normal file
304
test2/client/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Send, Bot, User, Loader2, ArrowLeft, Sparkles, Wrench, StopCircle } from 'lucide-react';
|
||||
import { startChatSession, sendMessageStream } from '../services/chatApi';
|
||||
import { getSessionHistory } from '../services/voiceApi';
|
||||
|
||||
export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack, onMessagesChange }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [streamingId, setStreamingId] = useState(null);
|
||||
const [toolsInUse, setToolsInUse] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// 初始化:创建聊天会话,优先从数据库加载完整历史
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
// 启动后端聊天会话(后端会从 DB 加载历史注入 Coze 上下文)
|
||||
await startChatSession(sessionId, voiceSubtitles);
|
||||
setIsInitialized(true);
|
||||
|
||||
// 从数据库加载完整对话历史(包含语音通话中的工具结果)
|
||||
let historyMsgs = [];
|
||||
try {
|
||||
const historyData = await getSessionHistory(sessionId, 20);
|
||||
if (historyData?.messages?.length > 0) {
|
||||
historyMsgs = historyData.messages.map((m, i) => ({
|
||||
id: `history-${i}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
fromVoice: true,
|
||||
}));
|
||||
console.log(`[ChatPanel] Loaded ${historyMsgs.length} messages from DB`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ChatPanel] DB history load failed, falling back to subtitles:', e.message);
|
||||
}
|
||||
|
||||
// 如果数据库没有历史,回退到 voiceSubtitles
|
||||
if (historyMsgs.length === 0 && voiceSubtitles && voiceSubtitles.length > 0) {
|
||||
historyMsgs = voiceSubtitles.map((s, i) => ({
|
||||
id: `voice-${i}`,
|
||||
role: s.role === 'user' ? 'user' : 'assistant',
|
||||
content: s.text,
|
||||
fromVoice: true,
|
||||
}));
|
||||
}
|
||||
|
||||
if (historyMsgs.length > 0) {
|
||||
setMessages(historyMsgs);
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
} catch (err) {
|
||||
console.error('[ChatPanel] Init failed:', err);
|
||||
setError('聊天会话初始化失败');
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [sessionId, voiceSubtitles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamingId, toolsInUse]);
|
||||
|
||||
// 消息变化时通知父组件(用于文字→语音上下文传递)
|
||||
useEffect(() => {
|
||||
if (onMessagesChange) {
|
||||
const finalMessages = messages.filter(m => !m.streaming && m.content);
|
||||
// 仅当有实际消息时才同步,避免挂载时清空父组件的 chatMessages 状态
|
||||
if (finalMessages.length > 0) {
|
||||
onMessagesChange(finalMessages);
|
||||
}
|
||||
}
|
||||
}, [messages, onMessagesChange]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current();
|
||||
abortRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
setStreamingId(null);
|
||||
setToolsInUse(null);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setInput('');
|
||||
setError(null);
|
||||
setToolsInUse(null);
|
||||
|
||||
const userMsg = { id: `user-${Date.now()}`, role: 'user', content: text };
|
||||
const assistantId = `assistant-${Date.now()}`;
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setIsLoading(true);
|
||||
setStreamingId(assistantId);
|
||||
|
||||
// 先插入一个空的 assistant 消息用于流式填充
|
||||
setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', streaming: true }]);
|
||||
|
||||
const abort = sendMessageStream(sessionId, text, {
|
||||
onChunk: (chunk) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + chunk } : m))
|
||||
);
|
||||
},
|
||||
onToolCall: (tools) => {
|
||||
setToolsInUse(tools);
|
||||
},
|
||||
onDone: (fullContent) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent, streaming: false } : m))
|
||||
);
|
||||
setIsLoading(false);
|
||||
setStreamingId(null);
|
||||
setToolsInUse(null);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
onError: (errMsg) => {
|
||||
setError(errMsg);
|
||||
setIsLoading(false);
|
||||
setStreamingId(null);
|
||||
setToolsInUse(null);
|
||||
// 移除空的流式消息
|
||||
setMessages((prev) => prev.filter((m) => m.id !== assistantId || m.content));
|
||||
},
|
||||
});
|
||||
|
||||
abortRef.current = abort;
|
||||
}, [input, isLoading, sessionId]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-slate-800/60 border border-slate-700/50 overflow-hidden flex flex-col h-[calc(100vh-8rem)] min-h-[400px] max-h-[700px]">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-700/40 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors"
|
||||
title="返回语音模式"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white leading-tight">{settings.botName}</h3>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">文字对话模式 · 方舟 LLM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{voiceSubtitles?.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-500/10 border border-violet-500/20">
|
||||
<Sparkles className="w-3 h-3 text-violet-400" />
|
||||
<span className="text-[10px] text-violet-400">语音转接</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{/* Transition notice */}
|
||||
{voiceSubtitles?.length > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="px-3 py-1 rounded-full bg-slate-700/30 border border-slate-600/20 text-[11px] text-slate-500">
|
||||
以下是语音通话中的对话记录,已无缝接续
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex items-start gap-2.5 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role !== 'user' && (
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
msg.fromVoice ? 'bg-slate-600/40' : 'bg-violet-500/20'
|
||||
}`}>
|
||||
<Bot className={`w-3.5 h-3.5 ${msg.fromVoice ? 'text-slate-400' : 'text-violet-400'}`} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`px-3 py-2 rounded-xl text-sm max-w-[75%] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? msg.fromVoice
|
||||
? 'bg-indigo-500/10 text-indigo-300/70 rounded-tr-sm'
|
||||
: 'bg-indigo-500/20 text-indigo-200 rounded-tr-sm'
|
||||
: msg.fromVoice
|
||||
? 'bg-slate-700/30 text-slate-300/70 rounded-tl-sm'
|
||||
: 'bg-slate-700/50 text-slate-200 rounded-tl-sm'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.fromVoice && (
|
||||
<span className="ml-1.5 text-[9px] text-slate-600 align-middle">🎙</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
msg.fromVoice ? 'bg-slate-600/40' : 'bg-indigo-500/20'
|
||||
}`}>
|
||||
<User className={`w-3.5 h-3.5 ${msg.fromVoice ? 'text-slate-400' : 'text-indigo-400'}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Voice→Text divider */}
|
||||
{messages.length > 0 && messages[messages.length - 1]?.fromVoice && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<div className="px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-[11px] text-emerald-400">
|
||||
↓ 文字对话开始 ↓
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool call indicator */}
|
||||
{toolsInUse && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-[11px] text-amber-400">
|
||||
<Wrench className="w-3 h-3 animate-pulse" />
|
||||
正在调用工具:{toolsInUse.join('、')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{isLoading && !streamingId && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-violet-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-3.5 h-3.5 text-violet-400" />
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-xl bg-slate-700/50 text-slate-400 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin inline mr-1.5" />
|
||||
思考中...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 px-3 py-1.5 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline hover:text-red-300">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t border-slate-700/40 flex-shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isInitialized ? '输入消息,Enter 发送...' : '正在初始化...'}
|
||||
disabled={!isInitialized || isLoading}
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2 rounded-xl bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 resize-none disabled:opacity-50"
|
||||
style={{ maxHeight: '80px' }}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-9 h-9 rounded-xl bg-red-500/80 hover:bg-red-500 text-white flex items-center justify-center transition-all flex-shrink-0"
|
||||
title="停止生成"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isInitialized}
|
||||
className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600 hover:from-violet-600 hover:to-indigo-700 text-white flex items-center justify-center transition-all disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
test2/client/src/components/SettingsPanel.jsx
Normal file
166
test2/client/src/components/SettingsPanel.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { X, Volume2, Bot, Mic2, Globe } from 'lucide-react';
|
||||
|
||||
export default function SettingsPanel({ settings, onChange, voiceConfig, onClose }) {
|
||||
const update = (key, value) => {
|
||||
onChange({ ...settings, [key]: value });
|
||||
};
|
||||
|
||||
const models = voiceConfig?.models || [
|
||||
{ value: '1.2.1.0', label: 'O2.0(推荐,精品音质)' },
|
||||
{ value: 'O', label: 'O(基础版)' },
|
||||
{ value: '2.2.0.0', label: 'SC2.0(推荐,声音复刻)' },
|
||||
{ value: 'SC', label: 'SC(基础版)' },
|
||||
];
|
||||
|
||||
const allSpeakers = voiceConfig?.speakers || [
|
||||
{ value: 'zh_female_vv_jupiter_bigtts', label: 'VV(活泼女声)', series: 'O' },
|
||||
{ value: 'zh_female_xiaohe_jupiter_bigtts', label: '小禾(甜美女声)', series: 'O' },
|
||||
{ value: 'zh_male_yunzhou_jupiter_bigtts', label: '云舟(沉稳男声)', series: 'O' },
|
||||
{ value: 'zh_male_xiaotian_jupiter_bigtts', label: '小天(磁性男声)', series: 'O' },
|
||||
];
|
||||
|
||||
// 根据模型系列过滤音色:O/O2.0 显示 O 系列,SC/SC2.0 显示对应系列
|
||||
const isOModel = settings.modelVersion === 'O' || settings.modelVersion.startsWith('1.');
|
||||
const speakers = allSpeakers.filter((s) => {
|
||||
if (!s.series) return true;
|
||||
if (isOModel) return s.series === 'O';
|
||||
if (settings.modelVersion === '2.2.0.0') return s.series === 'SC2.0' || s.series === 'SC';
|
||||
if (settings.modelVersion === 'SC') return s.series === 'SC' || s.series === 'SC2.0';
|
||||
return true;
|
||||
});
|
||||
|
||||
const tools = voiceConfig?.tools || [
|
||||
{ name: 'query_weather', description: '查询城市天气' },
|
||||
{ name: 'query_order', description: '查询订单状态' },
|
||||
{ name: 'search_knowledge', description: '知识库搜索' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-2xl bg-slate-800/70 border border-slate-700/50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-700/40 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">语音通话设置</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* AI 角色 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||
<Bot className="w-3.5 h-3.5" /> AI 角色设定
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.botName}
|
||||
onChange={(e) => update('botName', e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">角色描述 (system_role)</label>
|
||||
<textarea
|
||||
value={settings.systemRole}
|
||||
onChange={(e) => update('systemRole', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">说话风格 (speaking_style)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.speakingStyle}
|
||||
onChange={(e) => update('speakingStyle', e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色 & 模型 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||
<Volume2 className="w-3.5 h-3.5" /> 模型与音色
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">S2S 模型版本</label>
|
||||
<select
|
||||
value={settings.modelVersion}
|
||||
onChange={(e) => {
|
||||
const newModel = e.target.value;
|
||||
update('modelVersion', newModel);
|
||||
// 切换模型系列时自动选择第一个兼容音色
|
||||
const newIsO = newModel === 'O' || newModel.startsWith('1.');
|
||||
const currentIsO = settings.modelVersion === 'O' || settings.modelVersion.startsWith('1.');
|
||||
if (newIsO !== currentIsO) {
|
||||
const firstMatch = allSpeakers.find((s) => {
|
||||
if (newIsO) return s.series === 'O';
|
||||
if (newModel === '2.2.0.0') return s.series === 'SC2.0' || s.series === 'SC';
|
||||
return s.series === 'SC' || s.series === 'SC2.0';
|
||||
});
|
||||
if (firstMatch) onChange({ ...settings, modelVersion: newModel, speaker: firstMatch.value });
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">音色</label>
|
||||
<select
|
||||
value={settings.speaker}
|
||||
onChange={(e) => update('speaker', e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
>
|
||||
{speakers.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 联网搜索 */}
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400 mt-4">
|
||||
<Globe className="w-3.5 h-3.5" /> 功能开关
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableWebSearch}
|
||||
onChange={(e) => update('enableWebSearch', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-slate-600 bg-slate-700 text-violet-500 focus:ring-violet-500/30"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">启用联网搜索</span>
|
||||
</label>
|
||||
|
||||
{/* 已注册工具 */}
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400 mt-4">
|
||||
<Mic2 className="w-3.5 h-3.5" /> 已注册工具 (Function Calling)
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{tools.map((t) => (
|
||||
<div key={t.name} className="px-2.5 py-1.5 rounded-md bg-slate-700/30 border border-slate-600/20 text-xs">
|
||||
<span className="text-violet-400 font-mono">{t.name}</span>
|
||||
<span className="text-slate-500 ml-2">{t.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
test2/client/src/components/SubtitleDisplay.jsx
Normal file
82
test2/client/src/components/SubtitleDisplay.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageCircle, Bot, User } from 'lucide-react';
|
||||
|
||||
export default function SubtitleDisplay({ subtitles, isActive }) {
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [subtitles]);
|
||||
|
||||
const finalSubtitles = subtitles.filter((s) => s.isFinal);
|
||||
|
||||
if (!isActive && finalSubtitles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-700/40">
|
||||
<div className="px-4 py-2 flex items-center gap-1.5">
|
||||
<MessageCircle className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-xs font-medium text-slate-500">实时字幕</span>
|
||||
{finalSubtitles.length > 0 && (
|
||||
<span className="text-xs text-slate-600">({finalSubtitles.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="max-h-60 overflow-y-auto px-4 pb-4 space-y-2">
|
||||
{finalSubtitles.map((sub, i) => (
|
||||
<div
|
||||
key={`${sub.sequence}-${i}`}
|
||||
className={`flex items-start gap-2 ${
|
||||
sub.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
{sub.role !== 'user' && (
|
||||
<div className="w-6 h-6 rounded-full bg-violet-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-violet-400" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`px-3 py-1.5 rounded-xl text-sm max-w-[75%] ${
|
||||
sub.role === 'user'
|
||||
? 'bg-indigo-500/20 text-indigo-200 rounded-tr-sm'
|
||||
: 'bg-slate-700/50 text-slate-200 rounded-tl-sm'
|
||||
}`}
|
||||
>
|
||||
{sub.text}
|
||||
</div>
|
||||
{sub.role === 'user' && (
|
||||
<div className="w-6 h-6 rounded-full bg-indigo-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<User className="w-3.5 h-3.5 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Partial (non-final) subtitle being transcribed */}
|
||||
{subtitles.filter((s) => !s.isFinal).map((sub, i) => (
|
||||
<div
|
||||
key={`partial-${i}`}
|
||||
className={`flex items-start gap-2 opacity-50 ${
|
||||
sub.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
{sub.role !== 'user' && (
|
||||
<div className="w-6 h-6 rounded-full bg-slate-600/30 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-xl text-sm max-w-[75%] bg-slate-700/30 text-slate-400 italic">
|
||||
{sub.text}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isActive && finalSubtitles.length === 0 && (
|
||||
<p className="text-xs text-slate-600 text-center py-2">对话字幕将在这里显示...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
test2/client/src/components/VoicePanel.jsx
Normal file
170
test2/client/src/components/VoicePanel.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Mic, MicOff, Phone, PhoneOff, Loader2, MessageSquare } from 'lucide-react';
|
||||
import { useVoiceChat } from '../hooks/useVoiceChat';
|
||||
import SubtitleDisplay from './SubtitleDisplay';
|
||||
|
||||
export default function VoicePanel({ settings, onVoiceEnd, chatHistory = [], sessionId: parentSessionId }) {
|
||||
const {
|
||||
isActive,
|
||||
isMuted,
|
||||
isConnecting,
|
||||
subtitles,
|
||||
connectionState,
|
||||
error,
|
||||
duration,
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
clearError,
|
||||
} = useVoiceChat();
|
||||
|
||||
const formatTime = (s) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
start({
|
||||
botName: settings.botName,
|
||||
systemRole: settings.systemRole,
|
||||
speakingStyle: settings.speakingStyle,
|
||||
modelVersion: settings.modelVersion,
|
||||
speaker: settings.speaker,
|
||||
enableWebSearch: settings.enableWebSearch,
|
||||
chatHistory: chatHistory.length > 0 ? chatHistory.slice(-10) : undefined,
|
||||
parentSessionId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-slate-800/60 border border-slate-700/50 overflow-hidden">
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2.5 border-b border-slate-700/40 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isActive ? 'bg-emerald-400 animate-pulse' : isConnecting ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{isActive
|
||||
? `通话中 · ${formatTime(duration)}`
|
||||
: isConnecting
|
||||
? '正在连接...'
|
||||
: '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>模型: {settings.modelVersion}</span>
|
||||
<span>·</span>
|
||||
<span>{settings.botName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visualization Area */}
|
||||
<div className="relative flex flex-col items-center justify-center py-12 px-4">
|
||||
{/* Voice Wave Animation */}
|
||||
<div className="relative w-32 h-32 mb-6">
|
||||
{/* Outer rings */}
|
||||
{isActive && !isMuted && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-violet-500/10 animate-ping" style={{ animationDuration: '2s' }} />
|
||||
<div className="absolute inset-3 rounded-full bg-violet-500/10 animate-ping" style={{ animationDuration: '2.5s' }} />
|
||||
</>
|
||||
)}
|
||||
{/* Center circle */}
|
||||
<div
|
||||
className={`absolute inset-4 rounded-full flex items-center justify-center transition-all duration-300 ${
|
||||
isActive
|
||||
? isMuted
|
||||
? 'bg-slate-600 shadow-lg shadow-slate-600/20'
|
||||
: 'bg-gradient-to-br from-violet-500 to-indigo-600 shadow-lg shadow-violet-500/30'
|
||||
: 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-10 h-10 text-white animate-spin" />
|
||||
) : isActive ? (
|
||||
isMuted ? (
|
||||
<MicOff className="w-10 h-10 text-slate-300" />
|
||||
) : (
|
||||
<Mic className="w-10 h-10 text-white" />
|
||||
)
|
||||
) : (
|
||||
<Phone className="w-10 h-10 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Info */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">{settings.botName}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-sm">
|
||||
{isActive
|
||||
? isMuted
|
||||
? '麦克风已静音'
|
||||
: '正在聆听...'
|
||||
: '点击下方按钮开始语音通话'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isActive ? (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${
|
||||
isMuted
|
||||
? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
|
||||
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||
}`}
|
||||
title={isMuted ? '取消静音' : '静音'}
|
||||
>
|
||||
{isMuted ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await stop();
|
||||
if (onVoiceEnd) onVoiceEnd(result);
|
||||
}}
|
||||
className="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center transition-all shadow-lg shadow-red-500/25"
|
||||
title="结束通话并转接文字对话"
|
||||
>
|
||||
<PhoneOff className="w-6 h-6" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isConnecting}
|
||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 hover:from-violet-600 hover:to-indigo-700 text-white flex items-center justify-center transition-all shadow-lg shadow-violet-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="开始通话"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<Phone className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-4 px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs max-w-md text-center">
|
||||
{error}
|
||||
<button onClick={clearError} className="ml-2 underline hover:text-red-300">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<SubtitleDisplay subtitles={subtitles} isActive={isActive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
test2/client/src/hooks/useVoiceChat.js
Normal file
198
test2/client/src/hooks/useVoiceChat.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import rtcService from '../services/rtcService';
|
||||
import { prepareVoiceChat, startVoiceChat, stopVoiceChat, executeToolCall, executeFcCallback, sendSubtitle, forwardRoomMessage } from '../services/voiceApi';
|
||||
|
||||
export function useVoiceChat() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [subtitles, setSubtitles] = useState([]);
|
||||
const [connectionState, setConnectionState] = useState('disconnected');
|
||||
const [error, setError] = useState(null);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const sessionRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
rtcService.on('onSubtitle', (subtitle) => {
|
||||
setSubtitles((prev) => {
|
||||
if (subtitle.isFinal) {
|
||||
return [...prev.filter((s) => s.sequence !== subtitle.sequence), subtitle];
|
||||
}
|
||||
const idx = prev.findIndex((s) => s.sequence === subtitle.sequence && !s.isFinal);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = subtitle;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, subtitle];
|
||||
});
|
||||
|
||||
// 方案B:将用户最终字幕转发到后端,供 FC 回调时作为知识库查询
|
||||
if (subtitle.isFinal && subtitle.role === 'user' && subtitle.text) {
|
||||
const session = sessionRef.current;
|
||||
if (session) {
|
||||
sendSubtitle({
|
||||
sessionId: session.sessionId,
|
||||
roomId: session.roomId,
|
||||
text: subtitle.text,
|
||||
role: 'user',
|
||||
definite: true,
|
||||
sequence: subtitle.sequence,
|
||||
}).catch((err) => console.warn('[useVoiceChat] Send subtitle failed:', err.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rtcService.on('onToolCall', async (toolCall) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) {
|
||||
console.warn('[useVoiceChat] Tool call received but no active session');
|
||||
return;
|
||||
}
|
||||
console.log(`[useVoiceChat] Tool call: ${toolCall.function_name}, session: ${session.sessionId}`);
|
||||
try {
|
||||
// 构建FC回调消息格式
|
||||
const message = JSON.stringify([{
|
||||
id: toolCall.tool_call_id,
|
||||
function: {
|
||||
name: toolCall.function_name,
|
||||
arguments: toolCall.arguments
|
||||
},
|
||||
seq: 1
|
||||
}]);
|
||||
|
||||
// 调用fc_callback端点,传递必要的参数
|
||||
const result = await executeFcCallback({
|
||||
roomId: session.roomId,
|
||||
taskId: session.taskId || session.sessionId,
|
||||
type: 'tool_calls',
|
||||
message: message
|
||||
});
|
||||
console.log('[useVoiceChat] FC callback result:', result);
|
||||
} catch (err) {
|
||||
console.error('[useVoiceChat] FC callback failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 方案B:转发所有 RTC 房间消息到后端(可能包含 ASR/会话状态数据)
|
||||
rtcService.on('onRoomMessage', (msg) => {
|
||||
const session = sessionRef.current;
|
||||
if (session && msg.text) {
|
||||
forwardRoomMessage({
|
||||
roomId: session.roomId,
|
||||
uid: msg.uid,
|
||||
text: msg.text,
|
||||
}).catch(() => {}); // 静默失败,不影响主流程
|
||||
}
|
||||
});
|
||||
|
||||
rtcService.on('onConnectionStateChange', setConnectionState);
|
||||
rtcService.on('onError', (err) => setError(err?.message || 'RTC error'));
|
||||
|
||||
return () => {
|
||||
rtcService.destroy();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (options = {}) => {
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
const userId = `user_${Date.now().toString(36)}`;
|
||||
const { parentSessionId, ...startOptions } = options;
|
||||
|
||||
// 第一步:准备房间,获取 token
|
||||
const prepareRes = await prepareVoiceChat({ userId });
|
||||
if (!prepareRes.success) throw new Error(prepareRes.error);
|
||||
|
||||
const { sessionId, roomId, taskId, rtcToken, rtcAppId } = prepareRes.data;
|
||||
sessionRef.current = { sessionId, roomId, taskId, parentSessionId };
|
||||
|
||||
// 第二步:用户先进房
|
||||
await rtcService.init(rtcAppId);
|
||||
await rtcService.joinRoom(roomId, userId, rtcToken);
|
||||
console.log('[useVoiceChat] User joined room, now starting AI...');
|
||||
|
||||
// 第三步:用户已在房间内,启动 AI 语音对话
|
||||
const startRes = await startVoiceChat({ sessionId, ...startOptions });
|
||||
if (!startRes.success) throw new Error(startRes.error);
|
||||
|
||||
setIsActive(true);
|
||||
setSubtitles([]);
|
||||
setDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((d) => d + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('[useVoiceChat] Start failed:', err);
|
||||
setError(err.message || 'Failed to start voice chat');
|
||||
rtcService.destroy();
|
||||
if (sessionRef.current) {
|
||||
stopVoiceChat(sessionRef.current.sessionId).catch(() => {});
|
||||
sessionRef.current = null;
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
let result = { sessionId: null, subtitles: [] };
|
||||
try {
|
||||
// 在离开房间前,先从前端 state 中提取已确认的字幕
|
||||
const localFinalSubtitles = subtitles.filter((s) => s.isFinal);
|
||||
|
||||
await rtcService.leaveRoom();
|
||||
|
||||
if (sessionRef.current) {
|
||||
const sid = sessionRef.current.sessionId;
|
||||
const response = await stopVoiceChat(sid);
|
||||
const backendSubtitles = response?.data?.subtitles || [];
|
||||
|
||||
// 优先使用前端本地字幕(RTC 直接接收,更完整),后端字幕作为 fallback
|
||||
result = {
|
||||
sessionId: sid,
|
||||
subtitles: localFinalSubtitles.length > 0 ? localFinalSubtitles : backendSubtitles,
|
||||
};
|
||||
sessionRef.current = null;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
setIsActive(false);
|
||||
setIsMuted(false);
|
||||
setConnectionState('disconnected');
|
||||
} catch (err) {
|
||||
console.error('[useVoiceChat] Stop failed:', err);
|
||||
}
|
||||
return result;
|
||||
}, [subtitles]);
|
||||
|
||||
const toggleMute = useCallback(async () => {
|
||||
const next = !isMuted;
|
||||
await rtcService.setMuted(next);
|
||||
setIsMuted(next);
|
||||
}, [isMuted]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isMuted,
|
||||
isConnecting,
|
||||
subtitles,
|
||||
connectionState,
|
||||
error,
|
||||
duration,
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
19
test2/client/src/index.css
Normal file
19
test2/client/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
10
test2/client/src/main.jsx
Normal file
10
test2/client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
98
test2/client/src/services/chatApi.js
Normal file
98
test2/client/src/services/chatApi.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/chat',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
export async function startChatSession(sessionId, voiceSubtitles = [], systemPrompt = '') {
|
||||
const { data } = await api.post('/start', { sessionId, voiceSubtitles, systemPrompt });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessage(sessionId, message) {
|
||||
const { data } = await api.post('/send', { sessionId, message });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式发送消息,逐块回调
|
||||
* @param {string} sessionId
|
||||
* @param {string} message
|
||||
* @param {object} callbacks - { onChunk, onToolCall, onDone, onError }
|
||||
* @returns {function} abort - 调用可取消请求
|
||||
*/
|
||||
export function sendMessageStream(sessionId, message, { onChunk, onToolCall, onDone, onError }) {
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/chat/send-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, message }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ error: response.statusText }));
|
||||
onError?.(err.error || 'Request failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6));
|
||||
switch (data.type) {
|
||||
case 'chunk':
|
||||
onChunk?.(data.content);
|
||||
break;
|
||||
case 'tool_call':
|
||||
onToolCall?.(data.tools);
|
||||
break;
|
||||
case 'done':
|
||||
onDone?.(data.content);
|
||||
break;
|
||||
case 'error':
|
||||
onError?.(data.error);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip malformed SSE
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
onError?.(err.message || 'Stream failed');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
export async function getChatHistory(sessionId) {
|
||||
const { data } = await api.get(`/history/${sessionId}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteChatSession(sessionId) {
|
||||
const { data } = await api.delete(`/${sessionId}`);
|
||||
return data;
|
||||
}
|
||||
323
test2/client/src/services/rtcService.js
Normal file
323
test2/client/src/services/rtcService.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 火山引擎 RTC SDK 封装
|
||||
* 负责 WebRTC 音频流的建立和管理
|
||||
*/
|
||||
class RTCService {
|
||||
constructor() {
|
||||
this.engine = null;
|
||||
this.joined = false;
|
||||
this.callbacks = {
|
||||
onSubtitle: null,
|
||||
onAudioStatus: null,
|
||||
onConnectionStateChange: null,
|
||||
onError: null,
|
||||
onUserJoined: null,
|
||||
onUserLeft: null,
|
||||
onToolCall: null,
|
||||
onRoomMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
async init(appId) {
|
||||
if (this.engine) {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
try {
|
||||
const VERTC = await import('@volcengine/rtc');
|
||||
const createEngine = VERTC.default?.createEngine || VERTC.createEngine;
|
||||
const events = VERTC.default?.events || VERTC.events;
|
||||
|
||||
if (!createEngine) {
|
||||
throw new Error('Failed to load RTC SDK: createEngine not found');
|
||||
}
|
||||
|
||||
this.engine = createEngine(appId);
|
||||
this.events = events;
|
||||
|
||||
this.engine.on(events.onConnectionStateChanged, (state) => {
|
||||
console.log('[RTC] Connection state:', state);
|
||||
this.callbacks.onConnectionStateChange?.(state);
|
||||
});
|
||||
|
||||
if (events.onSubtitleStateChanged) {
|
||||
this.engine.on(events.onSubtitleStateChanged, (state) => {
|
||||
console.log('[RTC] Subtitle state changed:', state);
|
||||
});
|
||||
}
|
||||
|
||||
if (events.onSubtitleMessageReceived) {
|
||||
this.engine.on(events.onSubtitleMessageReceived, (subtitles) => {
|
||||
console.log('[RTC] Subtitle received:', subtitles.length, 'items');
|
||||
subtitles.forEach((sub) => {
|
||||
// bot 的 userId 以 'bot_' 开头,无 userId 或 bot_ 开头都是 assistant
|
||||
const isBot = !sub.userId || sub.userId.startsWith('bot_');
|
||||
this.callbacks.onSubtitle?.({
|
||||
text: sub.text,
|
||||
role: isBot ? 'assistant' : 'user',
|
||||
isFinal: sub.definite,
|
||||
sequence: sub.sequence,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.engine.on(events.onUserJoined, (info) => {
|
||||
console.log('[RTC] User joined:', info.userInfo?.userId);
|
||||
this.callbacks.onUserJoined?.(info);
|
||||
});
|
||||
|
||||
this.engine.on(events.onUserLeave, (info) => {
|
||||
console.log('[RTC] User left:', info.userInfo?.userId);
|
||||
this.callbacks.onUserLeft?.(info);
|
||||
});
|
||||
|
||||
this.engine.on(events.onError, (error) => {
|
||||
console.error('[RTC] Error:', error);
|
||||
this.callbacks.onError?.(error);
|
||||
});
|
||||
|
||||
// === Function Calling: 监听房间消息(SDK 回调参数是单个 event 对象) ===
|
||||
if (events.onRoomBinaryMessageReceived) {
|
||||
this.engine.on(events.onRoomBinaryMessageReceived, (event) => {
|
||||
try {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const raw = event.message;
|
||||
const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
console.log('[RTC][FC] Room binary from', uid, ':', text.substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text });
|
||||
const parsed = JSON.parse(text);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] Room binary (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (events.onRoomMessageReceived) {
|
||||
this.engine.on(events.onRoomMessageReceived, (event) => {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const msg = event.message || '';
|
||||
console.log('[RTC][FC] Room text from', uid, ':', String(msg).substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text: String(msg) });
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] Room text (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (events.onUserBinaryMessageReceived) {
|
||||
this.engine.on(events.onUserBinaryMessageReceived, (event) => {
|
||||
try {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const raw = event.message;
|
||||
const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
console.log('[RTC][FC] User binary from', uid, ':', text.substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text });
|
||||
const parsed = JSON.parse(text);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] User binary (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (events.onUserMessageReceived) {
|
||||
this.engine.on(events.onUserMessageReceived, (event) => {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const msg = event.message || '';
|
||||
console.log('[RTC][FC] User text from', uid, ':', String(msg).substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text: String(msg) });
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] User text (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === 诊断事件 ===
|
||||
if (events.onUserPublishStream) {
|
||||
this.engine.on(events.onUserPublishStream, (info) => {
|
||||
console.log('[RTC][DIAG] Remote user published stream:', info.userId, 'mediaType:', info.mediaType);
|
||||
});
|
||||
}
|
||||
if (events.onUserUnpublishStream) {
|
||||
this.engine.on(events.onUserUnpublishStream, (info) => {
|
||||
console.log('[RTC][DIAG] Remote user unpublished stream:', info.userId, 'mediaType:', info.mediaType);
|
||||
});
|
||||
}
|
||||
if (events.onAutoplayFailed) {
|
||||
this.engine.on(events.onAutoplayFailed, (info) => {
|
||||
console.error('[RTC][DIAG] ❌ Autoplay FAILED! Audio blocked by browser:', info);
|
||||
});
|
||||
}
|
||||
if (events.onPlayerEvent) {
|
||||
this.engine.on(events.onPlayerEvent, (info) => {
|
||||
console.log('[RTC][DIAG] Player event:', info);
|
||||
});
|
||||
}
|
||||
if (events.onRemoteStreamStats) {
|
||||
this.engine.on(events.onRemoteStreamStats, (stats) => {
|
||||
if (stats.audioRecvBytes > 0) {
|
||||
console.log('[RTC][DIAG] Receiving audio from:', stats.uid, 'bytes:', stats.audioRecvBytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 启用音频属性报告,检测是否有远端音频
|
||||
try {
|
||||
this.engine.enableAudioPropertiesReport?.({ interval: 3000 });
|
||||
if (events.onRemoteAudioPropertiesReport) {
|
||||
this.engine.on(events.onRemoteAudioPropertiesReport, (infos) => {
|
||||
infos?.forEach((info) => {
|
||||
if (info.audioPropertiesInfo?.linearVolume > 0) {
|
||||
console.log('[RTC][DIAG] 🔊 Remote audio detected! user:', info.streamKey?.userId, 'volume:', info.audioPropertiesInfo.linearVolume);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (events.onLocalAudioPropertiesReport) {
|
||||
this.engine.on(events.onLocalAudioPropertiesReport, (infos) => {
|
||||
infos?.forEach((info) => {
|
||||
if (info.audioPropertiesInfo?.linearVolume > 0) {
|
||||
console.log('[RTC][DIAG] 🎤 Local mic active, volume:', info.audioPropertiesInfo.linearVolume);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[RTC][DIAG] enableAudioPropertiesReport not available:', e.message);
|
||||
}
|
||||
|
||||
console.log('[RTC] Engine initialized with diagnostic listeners');
|
||||
console.log('[RTC] Available events:', Object.keys(events).filter(k => k.startsWith('on')).join(', '));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[RTC] Init failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async joinRoom(roomId, userId, token) {
|
||||
if (!this.engine) throw new Error('Engine not initialized');
|
||||
|
||||
await this.engine.joinRoom(
|
||||
token,
|
||||
roomId,
|
||||
{ userId },
|
||||
{
|
||||
isAutoPublish: true,
|
||||
isAutoSubscribeAudio: true,
|
||||
isAutoSubscribeVideo: false,
|
||||
}
|
||||
);
|
||||
|
||||
await this.engine.startAudioCapture();
|
||||
|
||||
// 激活字幕接收(必须在 joinRoom 之后调用)
|
||||
try {
|
||||
await this.engine.startSubtitle({});
|
||||
console.log('[RTC] Subtitle enabled');
|
||||
} catch (e) {
|
||||
console.warn('[RTC] startSubtitle failed:', e.message || e);
|
||||
}
|
||||
|
||||
this.joined = true;
|
||||
console.log(`[RTC] Joined room ${roomId} as ${userId}`);
|
||||
}
|
||||
|
||||
async leaveRoom() {
|
||||
if (!this.engine || !this.joined) return;
|
||||
try {
|
||||
await this.engine.stopAudioCapture();
|
||||
await this.engine.leaveRoom();
|
||||
this.joined = false;
|
||||
console.log('[RTC] Left room');
|
||||
} catch (e) {
|
||||
console.warn('[RTC] Leave room error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async setMuted(muted) {
|
||||
if (!this.engine) return;
|
||||
if (muted) {
|
||||
await this.engine.stopAudioCapture();
|
||||
} else {
|
||||
await this.engine.startAudioCapture();
|
||||
}
|
||||
}
|
||||
|
||||
_handleRoomMessage(uid, parsed) {
|
||||
console.log('[RTC][FC] Parsed message type:', parsed.type || parsed.event || 'unknown', 'from:', uid);
|
||||
|
||||
// 尝试多种可能的 tool call 消息格式
|
||||
let toolCalls = null;
|
||||
|
||||
// 格式1: { type: "function_call", data: { tool_calls: [...] } }
|
||||
if (parsed.type === 'function_call' && parsed.data?.tool_calls) {
|
||||
toolCalls = parsed.data.tool_calls;
|
||||
}
|
||||
// 格式2: { event: "function_call", tool_calls: [...] }
|
||||
else if (parsed.event === 'function_call' && parsed.tool_calls) {
|
||||
toolCalls = parsed.tool_calls;
|
||||
}
|
||||
// 格式3: { type: "conversation", data: { event: "function_call", ... } }
|
||||
else if (parsed.type === 'conversation' && parsed.data?.event === 'function_call') {
|
||||
toolCalls = parsed.data.tool_calls || [parsed.data];
|
||||
}
|
||||
// 格式4: 直接是 tool_calls 数组
|
||||
else if (parsed.tool_calls) {
|
||||
toolCalls = parsed.tool_calls;
|
||||
}
|
||||
// 格式5: 单个 function_call 对象
|
||||
else if (parsed.function?.name || parsed.function_name) {
|
||||
toolCalls = [parsed];
|
||||
}
|
||||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
console.log('[RTC][FC] ✅ Tool calls detected:', toolCalls.length);
|
||||
toolCalls.forEach((tc) => {
|
||||
const callId = tc.id || tc.tool_call_id || `tc_${Date.now()}`;
|
||||
const funcName = tc.function?.name || tc.function_name || 'unknown';
|
||||
const args = tc.function?.arguments || tc.arguments || '{}';
|
||||
console.log(`[RTC][FC] Tool call: ${funcName}(${args}), id=${callId}`);
|
||||
this.callbacks.onToolCall?.({ tool_call_id: callId, function_name: funcName, arguments: args });
|
||||
});
|
||||
} else {
|
||||
console.log('[RTC][FC] Message is not a tool call, full payload:', JSON.stringify(parsed).substring(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (event in this.callbacks) {
|
||||
this.callbacks[event] = callback;
|
||||
}
|
||||
}
|
||||
|
||||
off(event) {
|
||||
if (event in this.callbacks) {
|
||||
this.callbacks[event] = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.engine) {
|
||||
try {
|
||||
if (this.joined) {
|
||||
this.engine.stopAudioCapture().catch(() => {});
|
||||
this.engine.leaveRoom().catch(() => {});
|
||||
}
|
||||
this.engine.destroyEngine?.();
|
||||
} catch (e) {
|
||||
console.warn('[RTC] Destroy error:', e);
|
||||
}
|
||||
this.engine = null;
|
||||
this.joined = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rtcService = new RTCService();
|
||||
export default rtcService;
|
||||
82
test2/client/src/services/voiceApi.js
Normal file
82
test2/client/src/services/voiceApi.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/voice',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
export async function getVoiceConfig() {
|
||||
const { data } = await api.get('/config');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function prepareVoiceChat(params) {
|
||||
const { data } = await api.post('/prepare', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function startVoiceChat(params) {
|
||||
const { data } = await api.post('/start', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stopVoiceChat(sessionId) {
|
||||
const { data } = await api.post('/stop', { sessionId });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendSubtitle(params) {
|
||||
const { data } = await api.post('/subtitle', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSubtitles(sessionId) {
|
||||
const { data } = await api.get(`/subtitles/${sessionId}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getActiveSessions() {
|
||||
const { data } = await api.get('/sessions');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function forwardRoomMessage({ roomId, uid, text }) {
|
||||
const { data } = await api.post('/room_message', { roomId, uid, text });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function executeToolCall({ sessionId, toolCallId, functionName, arguments: args }) {
|
||||
const { data } = await api.post('/tool-callback', {
|
||||
sessionId,
|
||||
tool_call_id: toolCallId,
|
||||
function_name: functionName,
|
||||
arguments: args,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function executeFcCallback({ roomId, taskId, type, message }) {
|
||||
const { data } = await api.post('/fc_callback', {
|
||||
RoomID: roomId,
|
||||
TaskID: taskId,
|
||||
Type: type,
|
||||
Message: message,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// ========== 会话历史 API ==========
|
||||
const sessionApi = axios.create({
|
||||
baseURL: '/api/session',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
export async function getSessionHistory(sessionId, limit = 20) {
|
||||
const { data } = await sessionApi.get(`/${sessionId}/history`, { params: { limit, format: 'llm' } });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function switchSessionMode(sessionId, targetMode) {
|
||||
const { data } = await sessionApi.post(`/${sessionId}/switch`, { targetMode });
|
||||
return data.data;
|
||||
}
|
||||
20
test2/client/vite.config.js
Normal file
20
test2/client/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3012',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
23
test2/ecosystem.config.js
Normal file
23
test2/ecosystem.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// PM2 进程管理配置
|
||||
// 使用方式: pm2 start ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'bigwo-server',
|
||||
cwd: '/www/wwwroot/demo.tensorgrove.com.cn/server',
|
||||
script: 'app.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '512M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
error_file: '/var/log/bigwo/server-error.log',
|
||||
out_file: '/var/log/bigwo/server-out.log',
|
||||
merge_logs: true,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
37
test2/server/.env.example
Normal file
37
test2/server/.env.example
Normal file
@@ -0,0 +1,37 @@
|
||||
# ========== 服务端口 ==========
|
||||
PORT=3001
|
||||
|
||||
# ========== 火山引擎 RTC ==========
|
||||
VOLC_RTC_APP_ID=your_rtc_app_id
|
||||
VOLC_RTC_APP_KEY=your_rtc_app_key
|
||||
|
||||
# ========== 火山引擎 OpenAPI 签名 ==========
|
||||
VOLC_ACCESS_KEY_ID=your_access_key_id
|
||||
VOLC_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
|
||||
# ========== 豆包端到端语音大模型 ==========
|
||||
VOLC_S2S_APP_ID=your_s2s_app_id
|
||||
VOLC_S2S_TOKEN=your_s2s_access_token
|
||||
|
||||
# ========== 火山方舟 LLM(混合编排必需) ==========
|
||||
VOLC_ARK_ENDPOINT_ID=your_ark_endpoint_id
|
||||
|
||||
# ========== 可选:联网搜索 ==========
|
||||
VOLC_WEBSEARCH_API_KEY=your_websearch_api_key
|
||||
|
||||
# ========== 可选:声音复刻 ==========
|
||||
VOLC_S2S_SPEAKER_ID=your_custom_speaker_id
|
||||
|
||||
# ========== 可选:方舟私域知识库搜索 ==========
|
||||
# 方舟知识库 Dataset ID(在方舟控制台 -> 知识库 中获取,多个用逗号分隔)
|
||||
VOLC_ARK_KNOWLEDGE_BASE_IDS=your_knowledge_base_dataset_id
|
||||
# 知识库检索 top_k(返回最相关的文档数量,默认3)
|
||||
VOLC_ARK_KNOWLEDGE_TOP_K=3
|
||||
# 知识库检索相似度阈值(0-1,默认0.5)
|
||||
VOLC_ARK_KNOWLEDGE_THRESHOLD=0.5
|
||||
|
||||
# ========== 可选:Coze 知识库 ==========
|
||||
# Coze Personal Access Token(在 coze.cn -> API 设置 -> 个人访问令牌 中获取)
|
||||
COZE_API_TOKEN=your_coze_api_token
|
||||
# Coze 机器人 ID(需要已挂载知识库插件的 Bot)
|
||||
COZE_BOT_ID=your_coze_bot_id
|
||||
188
test2/server/app.js
Normal file
188
test2/server/app.js
Normal file
@@ -0,0 +1,188 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
const voiceRoutes = require('./routes/voice');
|
||||
const chatRoutes = require('./routes/chat');
|
||||
const sessionRoutes = require('./routes/session');
|
||||
|
||||
// ========== 环境变量校验 ==========
|
||||
function validateEnv() {
|
||||
const required = [
|
||||
{ key: 'VOLC_RTC_APP_ID', desc: 'RTC 应用 ID' },
|
||||
{ key: 'VOLC_RTC_APP_KEY', desc: 'RTC 应用密钥' },
|
||||
{ key: 'VOLC_ACCESS_KEY_ID', desc: '火山引擎 AccessKey ID' },
|
||||
{ key: 'VOLC_SECRET_ACCESS_KEY', desc: '火山引擎 Secret Access Key' },
|
||||
{ key: 'VOLC_S2S_APP_ID', desc: 'S2S 端到端语音 AppID' },
|
||||
{ key: 'VOLC_S2S_TOKEN', desc: 'S2S 端到端语音 Token' },
|
||||
{ key: 'VOLC_ARK_ENDPOINT_ID', desc: '方舟 LLM 推理接入点 ID' },
|
||||
];
|
||||
|
||||
const missing = [];
|
||||
const placeholder = [];
|
||||
|
||||
for (const { key, desc } of required) {
|
||||
const val = process.env[key];
|
||||
if (!val) {
|
||||
missing.push({ key, desc });
|
||||
} else if (val.startsWith('your_')) {
|
||||
placeholder.push({ key, desc });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.warn('\n⚠️ 以下必需环境变量未设置:');
|
||||
missing.forEach(({ key, desc }) => console.warn(` ❌ ${key} — ${desc}`));
|
||||
}
|
||||
if (placeholder.length > 0) {
|
||||
console.warn('\n⚠️ 以下环境变量仍为占位符(Mock 模式):');
|
||||
placeholder.forEach(({ key, desc }) => console.warn(` 🔶 ${key} — ${desc}`));
|
||||
}
|
||||
|
||||
const ready = missing.length === 0 && placeholder.length === 0;
|
||||
if (ready) {
|
||||
console.log('✅ 所有环境变量已配置');
|
||||
} else {
|
||||
console.warn('\n💡 请编辑 server/.env 填入真实凭证,当前将以 Mock 模式运行');
|
||||
}
|
||||
|
||||
// 可选变量提示
|
||||
const optional = [
|
||||
{ key: 'COZE_API_TOKEN', desc: 'Coze 智能体(文字对话)' },
|
||||
{ key: 'COZE_BOT_ID', desc: 'Coze Bot ID' },
|
||||
{ key: 'VOLC_WEBSEARCH_API_KEY', desc: '联网搜索' },
|
||||
{ key: 'VOLC_S2S_SPEAKER_ID', desc: '自定义音色' },
|
||||
{ key: 'VOLC_ARK_KNOWLEDGE_BASE_IDS', desc: '方舟私域知识库(语音)' },
|
||||
];
|
||||
const configuredOptional = optional.filter(({ key }) => {
|
||||
const v = process.env[key];
|
||||
return v && !v.startsWith('your_');
|
||||
});
|
||||
if (configuredOptional.length > 0) {
|
||||
console.log(`📦 可选功能已启用:${configuredOptional.map(o => o.desc).join('、')}`);
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
// ========== Express 应用 ==========
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
|
||||
// RTC Function Calling 回调不带 Content-Type,必须在标准 body parser 之前手动读取
|
||||
// 全局序列号:在 body 读取前同步分配,确保反映真实请求到达顺序
|
||||
let fcCallbackSeq = 0;
|
||||
app.post('/api/voice/fc_callback', (req, res, next) => {
|
||||
const seq = ++fcCallbackSeq; // 同步分配,在任何异步操作之前
|
||||
if (!req.headers['content-type']) {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const rawBuf = Buffer.concat(chunks);
|
||||
const raw = rawBuf.toString('utf-8');
|
||||
console.log(`[RawBody] seq=${seq} Read ${rawBuf.length} bytes`);
|
||||
// 将所有回调原始内容追加到日志文件
|
||||
try {
|
||||
const fs = require('fs');
|
||||
fs.appendFileSync('fc_all_callbacks.log', `\n=== SEQ=${seq} TIME=${new Date().toISOString()} BYTES=${rawBuf.length} ===\n${raw}\n`);
|
||||
} catch(e) {}
|
||||
try { req.body = JSON.parse(raw); } catch (e) { console.error('[RawBody] JSON parse failed:', e.message); req.body = { _raw: raw }; }
|
||||
req.body._seq = seq;
|
||||
next();
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
console.error('[RawBody] Error:', err.message);
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
req.body = req.body || {};
|
||||
req.body._seq = seq;
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
|
||||
// 请求日志中间件
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - start;
|
||||
if (req.path.startsWith('/api/')) {
|
||||
console.log(`[${req.method}] ${req.path} → ${res.statusCode} (${ms}ms)`);
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/voice', voiceRoutes);
|
||||
app.use('/api/chat', chatRoutes);
|
||||
app.use('/api/session', sessionRoutes);
|
||||
|
||||
// 静态文件服务
|
||||
app.use(express.static('../client/dist'));
|
||||
|
||||
// 处理单页应用路由
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
const envReady = !process.env.VOLC_RTC_APP_ID?.startsWith('your_');
|
||||
res.json({
|
||||
status: 'ok',
|
||||
mode: 's2s-hybrid',
|
||||
apiVersion: '2024-12-01',
|
||||
configured: envReady,
|
||||
features: {
|
||||
voiceChat: true,
|
||||
textChat: !!process.env.COZE_API_TOKEN && !process.env.COZE_API_TOKEN.startsWith('your_') && !!process.env.COZE_BOT_ID && !process.env.COZE_BOT_ID.startsWith('your_'),
|
||||
textChatProvider: 'coze',
|
||||
webSearch: !!process.env.VOLC_WEBSEARCH_API_KEY && !process.env.VOLC_WEBSEARCH_API_KEY.startsWith('your_'),
|
||||
customSpeaker: !!process.env.VOLC_S2S_SPEAKER_ID && !process.env.VOLC_S2S_SPEAKER_ID.startsWith('your_'),
|
||||
arkKnowledgeBase: !!process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS && !process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS.startsWith('your_'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 统一错误处理中间件
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error(`[Error] ${req.method} ${req.path}:`, err.message);
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: err.message || 'Internal Server Error',
|
||||
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
|
||||
});
|
||||
});
|
||||
|
||||
// 404 处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ success: false, error: `Route not found: ${req.method} ${req.path}` });
|
||||
});
|
||||
|
||||
// 启动服务(先初始化数据库)
|
||||
async function start() {
|
||||
try {
|
||||
await db.initialize();
|
||||
} catch (err) {
|
||||
console.error('[DB] MySQL initialization failed:', err.message);
|
||||
console.warn('[DB] Continuing without database — context switching will use in-memory fallback');
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log('\n========================================');
|
||||
console.log(` 🚀 Voice Chat Backend`);
|
||||
console.log(` 📡 http://localhost:${PORT}`);
|
||||
console.log(` 🔧 Mode: S2S端到端 + LLM混合 (API v2024-12-01)`);
|
||||
console.log('========================================\n');
|
||||
validateEnv();
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
71
test2/server/config/tools.js
Normal file
71
test2/server/config/tools.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const DEFAULT_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_knowledge',
|
||||
description: '【强制调用】这是最重要的工具,必须在回答任何用户问题之前调用。知识库包含企业的所有官方信息:产品介绍、退货退款政策、配送物流、保修售后、会员权益、常见问题等。无论用户问什么问题,你都必须先调用此工具查询知识库,基于知识库内容回答。如果知识库没有相关信息,再用自己的知识回答。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '用户问题的核心关键词或完整问题,如"退货政策"、"产品功能介绍"、"配送时间"' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'query_weather',
|
||||
description: '查询指定城市的天气信息',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string', description: '城市名称,如北京、上海' },
|
||||
},
|
||||
required: ['city'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'query_order',
|
||||
description: '根据订单号查询订单状态',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: { type: 'string', description: '订单编号' },
|
||||
},
|
||||
required: ['order_id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_current_time',
|
||||
description: '获取当前日期和时间',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'calculate',
|
||||
description: '计算数学表达式,支持加减乘除',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expression: { type: 'string', description: '数学表达式,如 (100+200)*0.8' },
|
||||
},
|
||||
required: ['expression'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = DEFAULT_TOOLS;
|
||||
172
test2/server/config/voiceChatConfig.js
Normal file
172
test2/server/config/voiceChatConfig.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
class VoiceChatConfigBuilder {
|
||||
/**
|
||||
* 构建 StartVoiceChat 的完整配置(S2S 端到端语音大模型 + LLM 混合编排)
|
||||
* OutputMode=1: 混合模式,S2S 处理普通对话,LLM 处理工具调用
|
||||
*/
|
||||
static build(options) {
|
||||
const {
|
||||
roomId,
|
||||
taskId,
|
||||
userId,
|
||||
botName = '小智',
|
||||
systemRole = '你是一个友善的智能助手。',
|
||||
speakingStyle = '请使用温和、清晰的口吻。',
|
||||
modelVersion = '1.2.1.0',
|
||||
speaker = 'zh_female_vv_jupiter_bigtts',
|
||||
tools = [],
|
||||
llmSystemPrompt = '',
|
||||
enableWebSearch = false,
|
||||
vadEndMs = 1200,
|
||||
chatHistory = [],
|
||||
} = options;
|
||||
|
||||
const botUserId = `bot_${uuidv4().slice(0, 8)}`;
|
||||
|
||||
const providerParams = {
|
||||
app: {
|
||||
appid: process.env.VOLC_S2S_APP_ID,
|
||||
token: process.env.VOLC_S2S_TOKEN,
|
||||
},
|
||||
dialog: this._buildDialogConfig(modelVersion, botName, systemRole, speakingStyle, enableWebSearch, chatHistory),
|
||||
tts: {
|
||||
speaker: speaker,
|
||||
},
|
||||
asr: {
|
||||
extra: {
|
||||
enable_custom_vad: true,
|
||||
end_smooth_window_ms: vadEndMs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// === 调试模式:纯 S2S(OutputMode=0),排除 LLM 干扰 ===
|
||||
// ARK 端点已配置正确,启用混合编排模式
|
||||
const DEBUG_PURE_S2S = false;
|
||||
|
||||
const llmConfig = {
|
||||
Mode: 'ArkV3',
|
||||
EndPointId: process.env.VOLC_ARK_ENDPOINT_ID,
|
||||
MaxTokens: 1024,
|
||||
Temperature: 0.1,
|
||||
TopP: 0.3,
|
||||
SystemMessages: [llmSystemPrompt || this._buildDefaultLLMPrompt(tools)],
|
||||
HistoryLength: 10,
|
||||
ThinkingType: 'disabled',
|
||||
};
|
||||
if (tools.length > 0) {
|
||||
llmConfig.Tools = tools;
|
||||
}
|
||||
|
||||
// 混合模式:通过 UserPrompts 传入聊天历史作为上下文(官方推荐方式)
|
||||
if (chatHistory && chatHistory.length > 0 && !DEBUG_PURE_S2S) {
|
||||
const userPrompts = chatHistory.slice(-10).map(m => ({
|
||||
Role: m.role === 'user' ? 'user' : 'assistant',
|
||||
Content: m.content,
|
||||
}));
|
||||
llmConfig.UserPrompts = userPrompts;
|
||||
console.log(`[VoiceChatConfig] Injected ${userPrompts.length} UserPrompts into LLMConfig`);
|
||||
}
|
||||
|
||||
const config = {
|
||||
AppId: process.env.VOLC_RTC_APP_ID,
|
||||
RoomId: roomId,
|
||||
TaskId: taskId,
|
||||
AgentConfig: {
|
||||
TargetUserId: [userId],
|
||||
WelcomeMessage: `你好,我是${botName},有什么需要帮忙的吗?`,
|
||||
UserId: botUserId,
|
||||
EnableConversationStateCallback: true,
|
||||
},
|
||||
Config: {
|
||||
S2SConfig: {
|
||||
Provider: 'volcano',
|
||||
OutputMode: DEBUG_PURE_S2S ? 0 : 1,
|
||||
ProviderParams: providerParams,
|
||||
},
|
||||
// 注意:S2S 端到端模式下不需要独立 TTSConfig
|
||||
// ExternalTextToSpeech 在 S2S 模式下不产生音频,只用 Command:function
|
||||
SubtitleConfig: {
|
||||
SubtitleMode: 1,
|
||||
},
|
||||
InterruptMode: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// 混合模式才需要 LLMConfig
|
||||
if (!DEBUG_PURE_S2S) {
|
||||
config.Config.LLMConfig = llmConfig;
|
||||
|
||||
// Function Calling 回调配置:RTC 服务通过此 URL 发送 tool call 请求
|
||||
if (tools.length > 0) {
|
||||
const serverUrl = process.env.FC_SERVER_URL || 'https://demo.tensorgrove.com.cn/api/voice/fc_callback';
|
||||
config.Config.FunctionCallingConfig = {
|
||||
ServerMessageUrl: serverUrl,
|
||||
ServerMessageSignature: process.env.FC_SIGNATURE || 'default_signature',
|
||||
};
|
||||
console.log(`[VoiceChatConfig] FunctionCallingConfig enabled, URL: ${serverUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[VoiceChatConfig] DEBUG_PURE_S2S:', DEBUG_PURE_S2S);
|
||||
console.log('[VoiceChatConfig] OutputMode:', config.Config.S2SConfig.OutputMode);
|
||||
console.log('[VoiceChatConfig] ProviderParams type:', typeof config.Config.S2SConfig.ProviderParams);
|
||||
console.log('[VoiceChatConfig] S2S AppId:', providerParams.app.appid);
|
||||
console.log('[VoiceChatConfig] S2S Token:', providerParams.app.token ? '***set***' : '***MISSING***');
|
||||
|
||||
return { config, botUserId };
|
||||
}
|
||||
|
||||
static _buildDialogConfig(modelVersion, botName, systemRole, speakingStyle, enableWebSearch, chatHistory = []) {
|
||||
const isOSeries = modelVersion === 'O' || modelVersion.startsWith('1.');
|
||||
const dialog = {
|
||||
extra: { model: modelVersion },
|
||||
};
|
||||
|
||||
// 如果有文字聊天历史,将其追加到 system_role 作为上下文
|
||||
let fullSystemRole = systemRole;
|
||||
if (chatHistory && chatHistory.length > 0) {
|
||||
const historyText = chatHistory
|
||||
.slice(-10)
|
||||
.map(m => `${m.role === 'user' ? '用户' : '助手'}:${m.content}`)
|
||||
.join('\n');
|
||||
fullSystemRole += `\n\n## 之前的对话记录(请延续此上下文)\n${historyText}`;
|
||||
console.log(`[VoiceChatConfig] Injected ${chatHistory.length} chat history messages into system_role`);
|
||||
}
|
||||
|
||||
if (isOSeries) {
|
||||
dialog.bot_name = botName;
|
||||
dialog.system_role = fullSystemRole;
|
||||
dialog.speaking_style = speakingStyle;
|
||||
} else {
|
||||
dialog.character_manifest = `${fullSystemRole}\n你的名字是${botName}。${speakingStyle}`;
|
||||
}
|
||||
|
||||
if (enableWebSearch && process.env.VOLC_WEBSEARCH_API_KEY) {
|
||||
dialog.extra.enable_volc_websearch = true;
|
||||
dialog.extra.volc_websearch_api_key = process.env.VOLC_WEBSEARCH_API_KEY;
|
||||
dialog.extra.volc_websearch_type = 'web_summary';
|
||||
dialog.extra.volc_websearch_no_result_message = '抱歉,我没有查到相关信息。';
|
||||
}
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
static _buildDefaultLLMPrompt(tools) {
|
||||
const toolNames = tools.map((t) => t.function?.name).filter(Boolean);
|
||||
if (toolNames.length === 0) {
|
||||
return '你是一个智能助手。对于所有问题直接回答即可。';
|
||||
}
|
||||
return `你是一个企业智能客服助手。你可以使用以下工具:${toolNames.join('、')}。
|
||||
|
||||
## 最高优先级规则
|
||||
1. 每次用户提问,你**必须**先调用 search_knowledge 工具查询知识库
|
||||
2. 收到工具返回的知识库内容后,你**必须完整、详细地朗读**知识库返回的内容给用户
|
||||
3. 不要省略、总结或缩写知识库的内容,要逐字朗读
|
||||
4. 如果知识库没有相关内容,再用你自己的知识简洁回答
|
||||
5. 如果知识库返回"未找到相关信息",直接告诉用户并提供建议`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VoiceChatConfigBuilder;
|
||||
149
test2/server/db/index.js
Normal file
149
test2/server/db/index.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
let pool = null;
|
||||
|
||||
/**
|
||||
* 初始化 MySQL 连接池 + 自动建表
|
||||
*/
|
||||
async function initialize() {
|
||||
// 先连接不指定数据库,确保数据库存在
|
||||
const tempConn = await mysql.createConnection({
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.MYSQL_PORT || '3306'),
|
||||
user: process.env.MYSQL_USER || 'root',
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
});
|
||||
const dbName = process.env.MYSQL_DATABASE || 'bigwo_chat';
|
||||
await tempConn.execute(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
await tempConn.end();
|
||||
|
||||
// 创建连接池
|
||||
pool = mysql.createPool({
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.MYSQL_PORT || '3306'),
|
||||
user: process.env.MYSQL_USER || 'root',
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
database: dbName,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
|
||||
// 建表
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id VARCHAR(128) PRIMARY KEY,
|
||||
user_id VARCHAR(128),
|
||||
mode ENUM('voice', 'chat') DEFAULT 'chat',
|
||||
created_at BIGINT,
|
||||
updated_at BIGINT,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
role ENUM('user', 'assistant', 'tool', 'system') NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
source ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot') NOT NULL,
|
||||
tool_name VARCHAR(64),
|
||||
created_at BIGINT,
|
||||
INDEX idx_session (session_id),
|
||||
INDEX idx_session_time (session_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
console.log(`[DB] MySQL connected: ${dbName}, tables ready`);
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接池
|
||||
*/
|
||||
function getPool() {
|
||||
if (!pool) throw new Error('[DB] Not initialized. Call initialize() first.');
|
||||
return pool;
|
||||
}
|
||||
|
||||
// ==================== Sessions ====================
|
||||
|
||||
async function createSession(sessionId, userId, mode = 'chat') {
|
||||
const now = Date.now();
|
||||
await pool.execute(
|
||||
'INSERT INTO sessions (id, user_id, mode, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE mode=VALUES(mode), updated_at=VALUES(updated_at)',
|
||||
[sessionId, userId || null, mode, now, now]
|
||||
);
|
||||
return { id: sessionId, userId, mode, created_at: now };
|
||||
}
|
||||
|
||||
async function updateSessionMode(sessionId, mode) {
|
||||
await pool.execute(
|
||||
'UPDATE sessions SET mode=?, updated_at=? WHERE id=?',
|
||||
[mode, Date.now(), sessionId]
|
||||
);
|
||||
}
|
||||
|
||||
async function getSession(sessionId) {
|
||||
const [rows] = await pool.execute('SELECT * FROM sessions WHERE id=?', [sessionId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// ==================== Messages ====================
|
||||
|
||||
async function addMessage(sessionId, role, content, source, toolName = null) {
|
||||
if (!content || content.trim() === '') return null;
|
||||
const now = Date.now();
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO messages (session_id, role, content, source, tool_name, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[sessionId, role, content, source, toolName, now]
|
||||
);
|
||||
// 更新 session 时间
|
||||
await pool.execute('UPDATE sessions SET updated_at=? WHERE id=?', [now, sessionId]);
|
||||
return { id: result.insertId, session_id: sessionId, role, content, source, tool_name: toolName, created_at: now };
|
||||
}
|
||||
|
||||
async function getMessages(sessionId, limit = 20) {
|
||||
const safeLimit = Math.max(1, Math.min(parseInt(limit) || 20, 100));
|
||||
const [rows] = await pool.query(
|
||||
'SELECT role, content, source, tool_name, created_at FROM messages WHERE session_id=? ORDER BY created_at ASC LIMIT ?',
|
||||
[sessionId, safeLimit]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getRecentMessages(sessionId, limit = 20) {
|
||||
// 获取最近 N 条,按时间正序返回
|
||||
const safeLimit = Math.max(1, Math.min(parseInt(limit) || 20, 100));
|
||||
const [rows] = await pool.query(
|
||||
`SELECT role, content, source, tool_name, created_at FROM messages
|
||||
WHERE session_id=? ORDER BY created_at DESC LIMIT ?`,
|
||||
[sessionId, safeLimit]
|
||||
);
|
||||
return rows.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话历史(格式化为 LLM 可用的 {role, content} 数组)
|
||||
* 合并 tool 消息到 assistant 消息
|
||||
*/
|
||||
async function getHistoryForLLM(sessionId, limit = 20) {
|
||||
const messages = await getRecentMessages(sessionId, limit);
|
||||
return messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
getPool,
|
||||
createSession,
|
||||
updateSessionMode,
|
||||
getSession,
|
||||
addMessage,
|
||||
getMessages,
|
||||
getRecentMessages,
|
||||
getHistoryForLLM,
|
||||
};
|
||||
171
test2/server/lib/token.js
Normal file
171
test2/server/lib/token.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
|
||||
* SPDX-license-identifier: BSD-3-Clause
|
||||
*
|
||||
* 火山引擎 RTC AccessToken 生成器
|
||||
* 来源:https://github.com/volcengine/rtc-aigc-demo/blob/main/Server/token.js
|
||||
*/
|
||||
|
||||
var crypto = require('crypto');
|
||||
|
||||
var randomInt = Math.floor(Math.random() * 0xFFFFFFFF);
|
||||
|
||||
const VERSION = "001";
|
||||
const VERSION_LENGTH = 3;
|
||||
|
||||
const APP_ID_LENGTH = 24;
|
||||
|
||||
privileges = {
|
||||
PrivPublishStream: 0,
|
||||
|
||||
// not exported, do not use directly
|
||||
privPublishAudioStream: 1,
|
||||
privPublishVideoStream: 2,
|
||||
privPublishDataStream: 3,
|
||||
|
||||
PrivSubscribeStream: 4,
|
||||
};
|
||||
|
||||
|
||||
module.exports.privileges = privileges;
|
||||
|
||||
// Initializes token struct by required parameters.
|
||||
var AccessToken = function (appID, appKey, roomID, userID) {
|
||||
let token = this;
|
||||
this.appID = appID;
|
||||
this.appKey = appKey;
|
||||
this.roomID = roomID;
|
||||
this.userID = userID;
|
||||
this.issuedAt = Math.floor(new Date() / 1000);
|
||||
this.nonce = randomInt;
|
||||
this.expireAt = 0;
|
||||
this.privileges = {};
|
||||
|
||||
// AddPrivilege adds permission for token with an expiration.
|
||||
this.addPrivilege = function (privilege, expireTimestamp) {
|
||||
if (token.privileges === undefined) {
|
||||
token.privileges = {}
|
||||
}
|
||||
token.privileges[privilege] = expireTimestamp;
|
||||
|
||||
if (privilege === privileges.PrivPublishStream) {
|
||||
token.privileges[privileges.privPublishVideoStream] = expireTimestamp;
|
||||
token.privileges[privileges.privPublishAudioStream] = expireTimestamp;
|
||||
token.privileges[privileges.privPublishDataStream] = expireTimestamp;
|
||||
}
|
||||
};
|
||||
|
||||
// ExpireTime sets token expire time, won't expire by default.
|
||||
// The token will be invalid after expireTime no matter what privilege's expireTime is.
|
||||
this.expireTime = function (expireTimestamp) {
|
||||
token.expireAt = expireTimestamp;
|
||||
};
|
||||
|
||||
this.packMsg = function () {
|
||||
var bufM = new ByteBuf();
|
||||
bufM.putUint32(token.nonce);
|
||||
bufM.putUint32(token.issuedAt);
|
||||
bufM.putUint32(token.expireAt);
|
||||
bufM.putString(token.roomID);
|
||||
bufM.putString(token.userID);
|
||||
bufM.putTreeMapUInt32(token.privileges);
|
||||
return bufM.pack()
|
||||
};
|
||||
|
||||
// Serialize generates the token string
|
||||
this.serialize = function () {
|
||||
var bytesM = this.packMsg();
|
||||
|
||||
var signature = encodeHMac(token.appKey, bytesM);
|
||||
var content = new ByteBuf().putBytes(bytesM).putBytes(signature).pack();
|
||||
|
||||
return (VERSION + token.appID + content.toString('base64'));
|
||||
};
|
||||
|
||||
// Verify checks if this token valid, called by server side.
|
||||
this.verify = function (key) {
|
||||
if (token.expireAt > 0 && Math.floor(new Date() / 1000) > token.expireAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
token.appKey = key;
|
||||
return encodeHMac(token.appKey, this.packMsg()).toString() === token.signature;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
module.exports.version = VERSION;
|
||||
module.exports.AccessToken = AccessToken;
|
||||
|
||||
var encodeHMac = function (key, message) {
|
||||
return crypto.createHmac('sha256', key).update(message).digest();
|
||||
};
|
||||
|
||||
var ByteBuf = function () {
|
||||
var that = {
|
||||
buffer: Buffer.alloc(1024)
|
||||
, position: 0
|
||||
};
|
||||
|
||||
|
||||
that.pack = function () {
|
||||
var out = Buffer.alloc(that.position);
|
||||
that.buffer.copy(out, 0, 0, out.length);
|
||||
return out;
|
||||
};
|
||||
|
||||
that.putUint16 = function (v) {
|
||||
that.buffer.writeUInt16LE(v, that.position);
|
||||
that.position += 2;
|
||||
return that;
|
||||
};
|
||||
|
||||
that.putUint32 = function (v) {
|
||||
that.buffer.writeUInt32LE(v, that.position);
|
||||
that.position += 4;
|
||||
return that;
|
||||
};
|
||||
|
||||
that.putBytes = function (bytes) {
|
||||
that.putUint16(bytes.length);
|
||||
bytes.copy(that.buffer, that.position);
|
||||
that.position += bytes.length;
|
||||
return that;
|
||||
};
|
||||
|
||||
that.putString = function (str) {
|
||||
return that.putBytes(Buffer.from(str));
|
||||
};
|
||||
|
||||
that.putTreeMap = function (map) {
|
||||
if (!map) {
|
||||
that.putUint16(0);
|
||||
return that;
|
||||
}
|
||||
|
||||
that.putUint16(Object.keys(map).length);
|
||||
for (var key in map) {
|
||||
that.putUint16(key);
|
||||
that.putString(map[key]);
|
||||
}
|
||||
|
||||
return that;
|
||||
};
|
||||
|
||||
that.putTreeMapUInt32 = function (map) {
|
||||
if (!map) {
|
||||
that.putUint16(0);
|
||||
return that;
|
||||
}
|
||||
|
||||
that.putUint16(Object.keys(map).length);
|
||||
for (var key in map) {
|
||||
that.putUint16(key);
|
||||
that.putUint32(map[key]);
|
||||
}
|
||||
|
||||
return that;
|
||||
};
|
||||
|
||||
return that;
|
||||
};
|
||||
1287
test2/server/package-lock.json
generated
Normal file
1287
test2/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
test2/server/package.json
Normal file
20
test2/server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "voice-chat-server",
|
||||
"version": "1.0.0",
|
||||
"description": "混合编排语音通话后端服务",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "node --watch app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@volcengine/openapi": "^1.36.0",
|
||||
"axios": "^1.6.2",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.18.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
241
test2/server/routes/chat.js
Normal file
241
test2/server/routes/chat.js
Normal file
@@ -0,0 +1,241 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const cozeChatService = require('../services/cozeChatService');
|
||||
const db = require('../db');
|
||||
|
||||
// 存储文字对话的会话状态(sessionId -> session)
|
||||
const chatSessions = new Map();
|
||||
|
||||
/**
|
||||
* POST /api/chat/start
|
||||
* 创建文字对话会话,可选传入语音通话的历史字幕
|
||||
*/
|
||||
router.post('/start', async (req, res) => {
|
||||
const { sessionId, voiceSubtitles = [] } = req.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
if (!cozeChatService.isConfigured()) {
|
||||
return res.status(500).json({ success: false, error: 'Coze 智能体未配置,请设置 COZE_API_TOKEN 和 COZE_BOT_ID' });
|
||||
}
|
||||
|
||||
// 优先从数据库加载完整历史(包含语音通话中的工具结果等)
|
||||
let voiceMessages = [];
|
||||
try {
|
||||
const dbHistory = await db.getHistoryForLLM(sessionId, 20);
|
||||
if (dbHistory.length > 0) {
|
||||
voiceMessages = dbHistory;
|
||||
console.log(`[Chat] Loaded ${dbHistory.length} messages from DB for session ${sessionId}`);
|
||||
}
|
||||
} catch (e) { console.warn('[DB] getHistoryForLLM failed:', e.message); }
|
||||
|
||||
// 如果数据库没有历史,回退到 voiceSubtitles
|
||||
if (voiceMessages.length === 0 && voiceSubtitles.length > 0) {
|
||||
const recentSubtitles = voiceSubtitles.slice(-10);
|
||||
for (const sub of recentSubtitles) {
|
||||
voiceMessages.push({
|
||||
role: sub.role === 'user' ? 'user' : 'assistant',
|
||||
content: sub.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据库会话模式为 chat
|
||||
try { await db.createSession(sessionId, `user_${sessionId.slice(0, 12)}`, 'chat'); } catch (e) {}
|
||||
|
||||
chatSessions.set(sessionId, {
|
||||
userId: `user_${sessionId.slice(0, 12)}`,
|
||||
conversationId: null,
|
||||
voiceMessages,
|
||||
createdAt: Date.now(),
|
||||
fromVoice: voiceSubtitles.length > 0 || voiceMessages.length > 0,
|
||||
});
|
||||
|
||||
console.log(`[Chat] Session started: ${sessionId}, fromVoice: ${voiceSubtitles.length > 0}, voiceMessages: ${voiceMessages.length}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId,
|
||||
messageCount: voiceMessages.length,
|
||||
fromVoice: voiceSubtitles.length > 0 || voiceMessages.length > 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat/send
|
||||
* 发送文字消息并获取 Coze 智能体回复(非流式)
|
||||
*/
|
||||
router.post('/send', async (req, res) => {
|
||||
try {
|
||||
const { sessionId, message } = req.body;
|
||||
|
||||
if (!sessionId || !message) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId and message are required' });
|
||||
}
|
||||
|
||||
let session = chatSessions.get(sessionId);
|
||||
|
||||
// 自动创建会话(如果不存在)
|
||||
if (!session) {
|
||||
session = {
|
||||
userId: `user_${sessionId.slice(0, 12)}`,
|
||||
conversationId: null,
|
||||
voiceMessages: [],
|
||||
createdAt: Date.now(),
|
||||
fromVoice: false,
|
||||
};
|
||||
chatSessions.set(sessionId, session);
|
||||
}
|
||||
|
||||
console.log(`[Chat] User(${sessionId}): ${message}`);
|
||||
|
||||
// 写入数据库:用户消息
|
||||
db.addMessage(sessionId, 'user', message, 'chat_user').catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
|
||||
// 首次对话时注入语音历史作为上下文,之后 Coze 自动管理会话历史
|
||||
const extraMessages = !session.conversationId ? session.voiceMessages : [];
|
||||
|
||||
const result = await cozeChatService.chat(
|
||||
session.userId,
|
||||
message,
|
||||
session.conversationId,
|
||||
extraMessages
|
||||
);
|
||||
|
||||
// 保存 Coze 返回的 conversationId
|
||||
session.conversationId = result.conversationId;
|
||||
|
||||
console.log(`[Chat] Assistant(${sessionId}): ${result.content?.substring(0, 100)}`);
|
||||
|
||||
// 写入数据库:AI 回复
|
||||
if (result.content) {
|
||||
db.addMessage(sessionId, 'assistant', result.content, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
content: result.content,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Chat] Send failed:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/chat/history/:sessionId
|
||||
* 获取会话状态
|
||||
*/
|
||||
router.get('/history/:sessionId', (req, res) => {
|
||||
const session = chatSessions.get(req.params.sessionId);
|
||||
if (!session) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
conversationId: session.conversationId,
|
||||
fromVoice: session.fromVoice,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat/send-stream
|
||||
* 流式发送文字消息(SSE),逐字输出 Coze 智能体回复
|
||||
*/
|
||||
router.post('/send-stream', async (req, res) => {
|
||||
const { sessionId, message } = req.body;
|
||||
|
||||
if (!sessionId || !message) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId and message are required' });
|
||||
}
|
||||
|
||||
let session = chatSessions.get(sessionId);
|
||||
if (!session) {
|
||||
session = {
|
||||
userId: `user_${sessionId.slice(0, 12)}`,
|
||||
conversationId: null,
|
||||
voiceMessages: [],
|
||||
createdAt: Date.now(),
|
||||
fromVoice: false,
|
||||
};
|
||||
chatSessions.set(sessionId, session);
|
||||
}
|
||||
|
||||
console.log(`[Chat][SSE] User(${sessionId}): ${message}`);
|
||||
|
||||
// 写入数据库:用户消息
|
||||
db.addMessage(sessionId, 'user', message, 'chat_user').catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
// 首次对话时注入语音历史作为上下文
|
||||
const extraMessages = !session.conversationId ? session.voiceMessages : [];
|
||||
|
||||
const result = await cozeChatService.chatStream(
|
||||
session.userId,
|
||||
message,
|
||||
session.conversationId,
|
||||
extraMessages,
|
||||
{
|
||||
onChunk: (text) => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'chunk', content: text })}\n\n`);
|
||||
},
|
||||
onDone: () => {},
|
||||
}
|
||||
);
|
||||
|
||||
// 保存 Coze 返回的 conversationId
|
||||
session.conversationId = result.conversationId;
|
||||
console.log(`[Chat][SSE] Assistant(${sessionId}): ${result.content?.substring(0, 100)}`);
|
||||
|
||||
// 写入数据库:AI 回复
|
||||
if (result.content) {
|
||||
db.addMessage(sessionId, 'assistant', result.content, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'done', content: result.content })}\n\n`);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error('[Chat][SSE] Stream failed:', error.message);
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/chat/:sessionId
|
||||
* 删除对话会话
|
||||
*/
|
||||
router.delete('/:sessionId', (req, res) => {
|
||||
chatSessions.delete(req.params.sessionId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// 定时清理过期会话(30 分钟无活动)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const TTL = 30 * 60 * 1000;
|
||||
for (const [id, session] of chatSessions) {
|
||||
if (now - session.createdAt > TTL) {
|
||||
chatSessions.delete(id);
|
||||
console.log(`[Chat] Session expired and cleaned: ${id}`);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
module.exports = router;
|
||||
75
test2/server/routes/session.js
Normal file
75
test2/server/routes/session.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
/**
|
||||
* GET /api/session/:id/history
|
||||
* 获取会话完整历史(用于文字↔语音切换时加载上下文)
|
||||
*/
|
||||
router.get('/:id/history', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const format = req.query.format || 'llm'; // 'llm' | 'full'
|
||||
|
||||
let messages;
|
||||
if (format === 'full') {
|
||||
messages = await db.getRecentMessages(id, limit);
|
||||
} else {
|
||||
messages = await db.getHistoryForLLM(id, limit);
|
||||
}
|
||||
|
||||
const session = await db.getSession(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: id,
|
||||
mode: session?.mode || null,
|
||||
messages,
|
||||
count: messages.length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Session] Get history failed:', err.message);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/session/:id/switch
|
||||
* 切换会话模式(voice ↔ chat),返回上下文历史
|
||||
*/
|
||||
router.post('/:id/switch', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { targetMode } = req.body; // 'voice' | 'chat'
|
||||
|
||||
if (!targetMode || !['voice', 'chat'].includes(targetMode)) {
|
||||
return res.status(400).json({ success: false, error: 'targetMode must be "voice" or "chat"' });
|
||||
}
|
||||
|
||||
// 更新会话模式
|
||||
await db.updateSessionMode(id, targetMode);
|
||||
|
||||
// 返回最近的对话历史供新模式使用
|
||||
const history = await db.getHistoryForLLM(id, 20);
|
||||
|
||||
console.log(`[Session] Switched ${id} to ${targetMode}, history: ${history.length} messages`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: id,
|
||||
mode: targetMode,
|
||||
history,
|
||||
count: history.length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Session] Switch failed:', err.message);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
559
test2/server/routes/voice.js
Normal file
559
test2/server/routes/voice.js
Normal file
@@ -0,0 +1,559 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const volcengine = require('../services/volcengine');
|
||||
const VoiceChatConfigBuilder = require('../config/voiceChatConfig');
|
||||
const ToolExecutor = require('../services/toolExecutor');
|
||||
const DEFAULT_TOOLS = require('../config/tools');
|
||||
const db = require('../db');
|
||||
|
||||
const activeSessions = new Map();
|
||||
const completedSessions = new Map();
|
||||
const roomToBotUserId = new Map();
|
||||
const roomToHumanUserId = new Map();
|
||||
const roomToSessionId = new Map();
|
||||
const roomToTaskId = new Map();
|
||||
const latestUserSpeech = new Map();
|
||||
const toolCallBuffers = new Map();
|
||||
|
||||
router.get('/config', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
models: [
|
||||
{ value: '1.2.1.0', label: 'O2.0(推荐,精品音质)' },
|
||||
{ value: 'O', label: 'O(基础版)' },
|
||||
{ value: '2.2.0.0', label: 'SC2.0(推荐,声音复刻)' },
|
||||
{ value: 'SC', label: 'SC(基础版)' },
|
||||
],
|
||||
speakers: [
|
||||
{ value: 'zh_female_vv_jupiter_bigtts', label: 'VV(活泼女声)', series: 'O' },
|
||||
{ value: 'zh_female_xiaohe_jupiter_bigtts', label: '小禾(甜美女声·台湾口音)', series: 'O' },
|
||||
{ value: 'zh_male_yunzhou_jupiter_bigtts', label: '云舟(沉稳男声)', series: 'O' },
|
||||
{ value: 'zh_male_xiaotian_jupiter_bigtts', label: '小天(磁性男声)', series: 'O' },
|
||||
{ value: 'saturn_common_female_1', label: 'Saturn 女声1', series: 'SC2.0' },
|
||||
{ value: 'saturn_common_male_1', label: 'Saturn 男声1', series: 'SC2.0' },
|
||||
{ value: 'ICL_common_female_1', label: 'ICL 女声1', series: 'SC' },
|
||||
{ value: 'ICL_common_male_1', label: 'ICL 男声1', series: 'SC' },
|
||||
],
|
||||
tools: DEFAULT_TOOLS.map((t) => ({
|
||||
name: t.function.name,
|
||||
description: t.function.description,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/prepare', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ success: false, error: 'userId is required' });
|
||||
}
|
||||
const sessionId = uuidv4();
|
||||
const roomId = `room_${sessionId.slice(0, 8)}`;
|
||||
const taskId = `task_${sessionId.slice(0, 8)}_${Date.now()}`;
|
||||
const rtcToken = volcengine.generateRTCToken(roomId, userId);
|
||||
activeSessions.set(sessionId, {
|
||||
roomId,
|
||||
taskId,
|
||||
userId,
|
||||
startTime: Date.now(),
|
||||
subtitles: [],
|
||||
started: false,
|
||||
});
|
||||
roomToTaskId.set(roomId, taskId);
|
||||
roomToSessionId.set(roomId, sessionId);
|
||||
console.log(`[Voice] Session prepared: ${sessionId}, room: ${roomId}, user: ${userId}`);
|
||||
try { await db.createSession(sessionId, userId, 'voice'); } catch (e) { console.warn('[DB] createSession failed:', e.message); }
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId,
|
||||
roomId,
|
||||
taskId,
|
||||
rtcToken,
|
||||
rtcAppId: process.env.VOLC_RTC_APP_ID,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Voice] Prepare failed:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/start', async (req, res) => {
|
||||
let session = null;
|
||||
try {
|
||||
const {
|
||||
sessionId,
|
||||
botName,
|
||||
systemRole,
|
||||
speakingStyle,
|
||||
modelVersion,
|
||||
speaker,
|
||||
enableWebSearch,
|
||||
chatHistory,
|
||||
} = req.body;
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
}
|
||||
session = activeSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ success: false, error: 'Session not found' });
|
||||
}
|
||||
if (session.started) {
|
||||
return res.json({ success: true, data: { message: 'Already started' } });
|
||||
}
|
||||
let effectiveChatHistory = chatHistory;
|
||||
if ((!chatHistory || chatHistory.length === 0) && sessionId) {
|
||||
try {
|
||||
const dbHistory = await db.getHistoryForLLM(sessionId, 20);
|
||||
if (dbHistory.length > 0) {
|
||||
effectiveChatHistory = dbHistory;
|
||||
console.log(`[Voice] Loaded ${dbHistory.length} messages from DB for session ${sessionId}`);
|
||||
}
|
||||
} catch (e) { console.warn('[DB] getHistoryForLLM failed:', e.message); }
|
||||
}
|
||||
console.log(`[Voice] chatHistory: ${effectiveChatHistory ? effectiveChatHistory.length : 'undefined'} messages`);
|
||||
const { config, botUserId } = VoiceChatConfigBuilder.build({
|
||||
roomId: session.roomId,
|
||||
taskId: session.taskId,
|
||||
userId: session.userId,
|
||||
botName,
|
||||
systemRole,
|
||||
speakingStyle,
|
||||
modelVersion,
|
||||
speaker,
|
||||
tools: DEFAULT_TOOLS,
|
||||
enableWebSearch,
|
||||
chatHistory: effectiveChatHistory,
|
||||
});
|
||||
session.botUserId = botUserId;
|
||||
roomToBotUserId.set(session.roomId, botUserId);
|
||||
roomToHumanUserId.set(session.roomId, session.userId);
|
||||
console.log(`[Voice] room=${session.roomId} botUserId=${botUserId} humanUserId=${session.userId}`);
|
||||
const result = await volcengine.startVoiceChat(config);
|
||||
session.started = true;
|
||||
// 捕获服务端可能分配的不同 TaskId
|
||||
const serverTaskId = result?.Result?.TaskId || result?.Result?.task_id;
|
||||
if (serverTaskId && serverTaskId !== session.taskId) {
|
||||
console.log(`[Voice] Server assigned different TaskId: ${serverTaskId} (ours: ${session.taskId})`);
|
||||
roomToTaskId.set(session.roomId, serverTaskId);
|
||||
session.taskId = serverTaskId;
|
||||
}
|
||||
console.log(`[Voice] Session started: ${sessionId}, TaskId=${session.taskId}`);
|
||||
res.json({
|
||||
success: true,
|
||||
data: { startResult: result },
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error.response?.data || error.message;
|
||||
console.error('[Voice] Start failed:', JSON.stringify(detail, null, 2));
|
||||
if (session) {
|
||||
try {
|
||||
await volcengine.stopVoiceChat({
|
||||
AppId: process.env.VOLC_RTC_APP_ID,
|
||||
RoomId: session.roomId,
|
||||
TaskId: session.taskId,
|
||||
});
|
||||
console.log(`[Voice] Stopped failed session`);
|
||||
} catch (stopErr) {
|
||||
console.warn('[Voice] Stop failed during error handling:', stopErr.message);
|
||||
}
|
||||
}
|
||||
res.status(500).json({ success: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/stop', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
const session = activeSessions.get(sessionId);
|
||||
if (session) {
|
||||
await volcengine.stopVoiceChat({
|
||||
AppId: process.env.VOLC_RTC_APP_ID,
|
||||
RoomId: session.roomId,
|
||||
TaskId: session.taskId,
|
||||
});
|
||||
const duration = Math.floor((Date.now() - session.startTime) / 1000);
|
||||
console.log(`[Voice] Session stopped: ${sessionId}, duration: ${duration}s, subtitles: ${session.subtitles.length}`);
|
||||
if (session.subtitles.length > 0) {
|
||||
completedSessions.set(sessionId, {
|
||||
subtitles: session.subtitles,
|
||||
duration,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
setTimeout(() => completedSessions.delete(sessionId), 30 * 60 * 1000);
|
||||
}
|
||||
activeSessions.delete(sessionId);
|
||||
roomToTaskId.delete(session.roomId);
|
||||
roomToSessionId.delete(session.roomId);
|
||||
roomToBotUserId.delete(session.roomId);
|
||||
roomToHumanUserId.delete(session.roomId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
duration,
|
||||
subtitleCount: session.subtitles.length,
|
||||
subtitles: session.subtitles,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.json({ success: true, data: { message: 'Session not found or already stopped' } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Voice] Stop failed:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/subtitle', (req, res) => {
|
||||
try {
|
||||
const { sessionId, roomId, text, role, definite, sequence } = req.body;
|
||||
const session = activeSessions.get(sessionId);
|
||||
if (definite && text) {
|
||||
const subtitleRole = role === 'user' ? 'user' : 'assistant';
|
||||
if (session) {
|
||||
session.subtitles.push({ text, role: subtitleRole, timestamp: Date.now(), sequence });
|
||||
}
|
||||
const sid = sessionId || (session && roomToSessionId.get(session.roomId));
|
||||
if (sid) {
|
||||
const source = subtitleRole === 'user' ? 'voice_asr' : 'voice_bot';
|
||||
db.addMessage(sid, subtitleRole, text, source).catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
}
|
||||
if (subtitleRole === 'user') {
|
||||
const rid = roomId || (session && session.roomId) || '';
|
||||
if (rid) {
|
||||
latestUserSpeech.set(rid, { text, timestamp: Date.now() });
|
||||
console.log(`[Subtitle][user][${rid}] "${text}"`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Subtitle][assistant] ${text}`);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Subtitle] Error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/subtitles/:sessionId', (req, res) => {
|
||||
const session = activeSessions.get(req.params.sessionId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: session ? session.subtitles : [],
|
||||
});
|
||||
});
|
||||
|
||||
function extractReadableText(chunks) {
|
||||
const raw = chunks.join('');
|
||||
let decoded = raw;
|
||||
try {
|
||||
decoded = decoded.replace(/\\\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
return String.fromCharCode(parseInt(hex, 16));
|
||||
});
|
||||
decoded = decoded.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
return String.fromCharCode(parseInt(hex, 16));
|
||||
});
|
||||
} catch (e) { }
|
||||
const chineseChars = decoded.match(/[\u4e00-\u9fff\u3400-\u4dbf]+/g) || [];
|
||||
const skipWords = new Set(['id', 'type', 'function', 'name', 'arguments', 'query', 'object', 'string']);
|
||||
const englishWords = (decoded.match(/[a-zA-Z]{2,}/g) || [])
|
||||
.filter(w => !skipWords.has(w.toLowerCase()));
|
||||
const parts = [...chineseChars, ...englishWords];
|
||||
const result = parts.join(' ').trim();
|
||||
console.log(`[FC] extractReadableText: chinese=[${chineseChars.join(',')}] english=[${englishWords.join(',')}] → "${result}"`);
|
||||
return result;
|
||||
}
|
||||
|
||||
let fcCallbackSeq = 0;
|
||||
router.post('/fc_callback', async (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Object.keys(body).length === 0) {
|
||||
console.error('[FC] Empty body');
|
||||
return res.status(400).json({ success: false, error: 'Empty body' });
|
||||
}
|
||||
const { Message, Signature, Type, RoomID, TaskID, TaskType, AppID, AppId, room_id, task_id, roomId, taskId } = body;
|
||||
const effectiveRoomId = RoomID || room_id || roomId;
|
||||
const effectiveTaskId = TaskID || task_id || taskId;
|
||||
const effectiveAppId = AppID || AppId || process.env.VOLC_RTC_APP_ID;
|
||||
const seq = body._seq || ++fcCallbackSeq;
|
||||
console.log(`[FC] >>> Callback received: seq=${seq} Type="${Type}" Room=${effectiveRoomId} Task=${effectiveTaskId} TaskType=${TaskType}`);
|
||||
let msgObj = null;
|
||||
try {
|
||||
msgObj = typeof Message === 'string' ? JSON.parse(Message) : Message;
|
||||
} catch (e) {
|
||||
console.error('[FC] Failed to parse Message:', e.message);
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
if (Type === 'tool_calls' && Array.isArray(msgObj) && msgObj.length > 0) {
|
||||
const tc = msgObj[0];
|
||||
const chunkId = tc.id || '';
|
||||
const chunkName = tc.function?.name || '';
|
||||
const chunkArgs = tc.function?.arguments || '';
|
||||
const existing = toolCallBuffers.get(effectiveTaskId);
|
||||
|
||||
if (existing && existing.triggered) {
|
||||
const userSpeech = latestUserSpeech.get(effectiveRoomId);
|
||||
const hasNewInput = userSpeech && (Date.now() - userSpeech.timestamp < 10000);
|
||||
if (hasNewInput) {
|
||||
console.log(`[FC] [FormatA] New user input detected, clearing cooldown for room=${effectiveRoomId}`);
|
||||
toolCallBuffers.delete(effectiveTaskId);
|
||||
} else {
|
||||
// 扩展 cooldown 到 30 秒,防止 LLM 在 KB 查询期间无限重试
|
||||
const cooldownMs = existing.resultSentAt ? 30000 : 15000;
|
||||
const elapsed = existing.resultSentAt
|
||||
? (Date.now() - existing.resultSentAt)
|
||||
: (Date.now() - existing.createdAt);
|
||||
if (elapsed < cooldownMs) {
|
||||
console.log(`[FC] [FormatA] Cooldown active (${elapsed}ms < ${cooldownMs}ms), ignoring retry for TaskID=${effectiveTaskId}`);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
console.log(`[FC] [FormatA] Cooldown expired (${elapsed}ms >= ${cooldownMs}ms), allowing new call for TaskID=${effectiveTaskId}`);
|
||||
toolCallBuffers.delete(effectiveTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolCallBuffers.has(effectiveTaskId)) {
|
||||
toolCallBuffers.set(effectiveTaskId, {
|
||||
id: '', name: '', chunks: [], triggered: false,
|
||||
RoomID: effectiveRoomId, AppID: effectiveAppId, S2STaskID: effectiveTaskId, createdAt: Date.now(), timer: null,
|
||||
});
|
||||
console.log(`[FC] [FormatA] New buffer created for TaskID=${effectiveTaskId}, room=${effectiveRoomId}`);
|
||||
}
|
||||
|
||||
const buf = toolCallBuffers.get(effectiveTaskId);
|
||||
if (chunkId && !buf.id) buf.id = chunkId;
|
||||
if (chunkName && !buf.name) buf.name = chunkName;
|
||||
if (chunkArgs) {
|
||||
buf.chunks.push({ seq: tc.seq || 0, args: chunkArgs });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
if (buf.timer) clearTimeout(buf.timer);
|
||||
buf.timer = setTimeout(async () => { // 500ms 收集 chunks
|
||||
const b = toolCallBuffers.get(effectiveTaskId);
|
||||
if (!b || b.triggered) return;
|
||||
b.triggered = true;
|
||||
const toolName = b.name || 'search_knowledge';
|
||||
const sortedChunks = b.chunks.sort((a, b) => a.seq - b.seq);
|
||||
const allArgs = sortedChunks.map(c => c.args).join('');
|
||||
console.log(`[FC] [FormatA] 500ms timeout, ${b.chunks.length} chunks collected, name="${toolName}"`);
|
||||
|
||||
const s2sTaskId = roomToTaskId.get(b.RoomID) || b.S2STaskID || effectiveTaskId;
|
||||
console.log(`[FC] TaskId resolution: roomToTaskId=${roomToTaskId.get(b.RoomID)} callback=${b.S2STaskID} → using=${s2sTaskId}`);
|
||||
// 不再单独发 interrupt 命令,ExternalTextToSpeech 的 InterruptMode:1 已包含打断功能
|
||||
|
||||
let parsedArgs = null;
|
||||
try {
|
||||
parsedArgs = JSON.parse(allArgs);
|
||||
console.log(`[FC] [FormatA] JSON.parse succeeded: ${JSON.stringify(parsedArgs)}`);
|
||||
} catch (e) {
|
||||
const userSpeech = latestUserSpeech.get(b.RoomID);
|
||||
if (userSpeech && (Date.now() - userSpeech.timestamp < 30000)) {
|
||||
console.log(`[FC] [FormatA] Using ASR user speech: "${userSpeech.text}"`);
|
||||
parsedArgs = { query: userSpeech.text };
|
||||
} else {
|
||||
const extractedText = extractReadableText(b.chunks.map(c => c.args));
|
||||
console.log(`[FC] [FormatA] No ASR text, extracted from chunks: "${extractedText}"`);
|
||||
parsedArgs = { query: extractedText || '' };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[FC] ⚡ Starting KB query (no pre-query interrupt)');
|
||||
const kbResult = await ToolExecutor.execute(toolName, parsedArgs);
|
||||
|
||||
try {
|
||||
const result = kbResult;
|
||||
const resultStr = JSON.stringify(result);
|
||||
console.log(`[FC] Tool result (${toolName}): ${resultStr.substring(0, 500)}`);
|
||||
let contentText = resultStr;
|
||||
try {
|
||||
if (result && result.results && Array.isArray(result.results)) {
|
||||
contentText = result.results.map(r => r.content || JSON.stringify(r)).join('\n');
|
||||
} else if (result && result.error) {
|
||||
contentText = result.error;
|
||||
} else if (typeof result === 'string') {
|
||||
contentText = result;
|
||||
}
|
||||
} catch (e) { }
|
||||
const dbSessionId = roomToSessionId.get(b.RoomID);
|
||||
if (dbSessionId) {
|
||||
db.addMessage(dbSessionId, 'assistant', contentText, 'voice_tool', toolName)
|
||||
.catch(e => console.warn('[DB] addMessage(tool) failed:', e.message));
|
||||
}
|
||||
console.log(`[FC] Knowledge base content (${contentText.length} chars): ${contentText.substring(0, 200)}${contentText.length > 200 ? '...' : ''}`);
|
||||
b.resultSentAt = Date.now();
|
||||
|
||||
// === 策略:只用 Command:function 回传结果给 LLM ===
|
||||
// 根因分析:
|
||||
// 1. ExternalTextToSpeech 在 S2S 端到端模式下不产生可听见的音频(API返回ok但无声音)
|
||||
// 2. ExternalTextToSpeech InterruptMode=1 会打断正在播放的 S2S 回复,导致用户听到中断
|
||||
// 3. Command:function 是官方自定义 FC 模式的正确回传方式
|
||||
// 流程:Command:function → LLM 收到工具结果 → LLM 生成回复 → S2S 朗读
|
||||
const toolCallId = b.id || 'unknown_call_id';
|
||||
const functionContent = contentText.length > 1500
|
||||
? contentText.substring(0, 1500) + '……(内容较长,以上为主要部分)'
|
||||
: contentText;
|
||||
const funcMsg = JSON.stringify({
|
||||
ToolCallID: toolCallId,
|
||||
Content: functionContent,
|
||||
});
|
||||
|
||||
let activeTaskId = s2sTaskId;
|
||||
try {
|
||||
console.log(`[FC] ★ Sending Command:function (ToolCallID=${toolCallId}, content=${functionContent.length} chars)`);
|
||||
await volcengine.updateVoiceChat({
|
||||
AppId: effectiveAppId,
|
||||
RoomId: b.RoomID,
|
||||
TaskId: activeTaskId,
|
||||
Command: 'function',
|
||||
Message: funcMsg,
|
||||
});
|
||||
console.log('[FC] ✅ Command:function sent OK → LLM will generate S2S response with KB content');
|
||||
} catch (funcErr) {
|
||||
console.error('[FC] ✖ Command:function failed:', funcErr.message);
|
||||
// 如果正式 TaskId 失败,尝试回调 TaskId
|
||||
if (activeTaskId !== b.S2STaskID) {
|
||||
try {
|
||||
console.log(`[FC] Retrying Command:function with callback TaskID=${b.S2STaskID}`);
|
||||
activeTaskId = b.S2STaskID;
|
||||
await volcengine.updateVoiceChat({
|
||||
AppId: effectiveAppId,
|
||||
RoomId: b.RoomID,
|
||||
TaskId: activeTaskId,
|
||||
Command: 'function',
|
||||
Message: funcMsg,
|
||||
});
|
||||
console.log('[FC] ✅ Command:function retry OK');
|
||||
} catch (retryErr) {
|
||||
console.error('[FC] ✖ Command:function retry also failed:', retryErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[FC] Final result: Command:function sent (${functionContent.length} chars)`);
|
||||
} catch (err) {
|
||||
console.error(`[FC] Tool execution failed:`, err.message);
|
||||
console.error(`[FC] Error details:`, err);
|
||||
}
|
||||
}, 500); // 从1s减到500ms,减少等待
|
||||
return;
|
||||
}
|
||||
|
||||
if (msgObj && typeof msgObj === 'object' && !Array.isArray(msgObj)) {
|
||||
const eventType = msgObj.event_type;
|
||||
console.log(`[FC] [FormatB] event_type="${eventType}"`);
|
||||
if (eventType === 'function_calling') {
|
||||
const funcName = msgObj.function || '';
|
||||
const toolCallId = msgObj.tool_call_id || '';
|
||||
const responseId = msgObj.response_id || '';
|
||||
console.log(`[FC] [Information] FC notification: func=${funcName} toolCallId=${toolCallId} responseId=${responseId}`);
|
||||
res.json({ success: true });
|
||||
// ExternalTextToSpeech 在 S2S 模式下不产生音频,不再发送安抚语
|
||||
// LLM 的 tool_calls 会触发 FormatA 分支执行工具并通过 Command:function 回传结果
|
||||
console.log(`[FC] [Information] FC notification received, waiting for tool_calls`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (msgObj && typeof msgObj === 'object') {
|
||||
const asrText = msgObj.text || msgObj.asr_text || msgObj.content ||
|
||||
msgObj.user_text || msgObj.transcript ||
|
||||
(msgObj.data && (msgObj.data.text || msgObj.data.asr_text || msgObj.data.content));
|
||||
const role = msgObj.role || msgObj.speaker || msgObj.data?.role || '';
|
||||
const isUser = !role || role === 'user' || role === 'human';
|
||||
if (asrText && isUser && RoomID) {
|
||||
latestUserSpeech.set(RoomID, { text: asrText, timestamp: Date.now() });
|
||||
console.log(`[FC] [ConvState] Stored user speech for ${RoomID}: "${asrText}"`);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[FC] Error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/room_message', (req, res) => {
|
||||
try {
|
||||
const { roomId, uid, text } = req.body;
|
||||
if (!roomId || !text) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
const jsonStart = text.search(/[\[{]/);
|
||||
if (jsonStart < 0) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
const jsonStr = text.substring(jsonStart);
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(jsonStr); } catch (e) {
|
||||
const textMatch = jsonStr.match(/"text"\s*:\s*"([^"]+)"/);
|
||||
if (textMatch && textMatch[1]) {
|
||||
const extractedText = textMatch[1];
|
||||
const userIdMatch = jsonStr.match(/"userId"\s*:\s*"([^"]+)"/);
|
||||
const subtitleUserId = userIdMatch ? userIdMatch[1] : '';
|
||||
const isUserSpeech = subtitleUserId && !subtitleUserId.startsWith('bot_');
|
||||
if (isUserSpeech && extractedText) {
|
||||
latestUserSpeech.set(roomId, { text: extractedText, timestamp: Date.now(), source: 'room_regex' });
|
||||
console.log(`[RoomMsg] ✅ Stored user speech (regex) for ${roomId}: "${extractedText}"`);
|
||||
}
|
||||
}
|
||||
return res.json({ success: true });
|
||||
}
|
||||
if (parsed && parsed.data && Array.isArray(parsed.data)) {
|
||||
parsed.data.forEach(sub => {
|
||||
const subText = sub.text || '';
|
||||
const subUserId = sub.userId || sub.user_id || '';
|
||||
const isDefinite = sub.definite === true;
|
||||
const isUserSpeech = subUserId && !subUserId.startsWith('bot_');
|
||||
if (subText && isUserSpeech && isDefinite) {
|
||||
latestUserSpeech.set(roomId, { text: subText, timestamp: Date.now(), source: 'room_subtitle' });
|
||||
console.log(`[RoomMsg] ✅ Stored user speech for ${roomId}: "${subText}"`);
|
||||
}
|
||||
});
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const asrText = parsed.text || parsed.asr_text || parsed.content ||
|
||||
parsed.user_text || parsed.transcript ||
|
||||
(parsed.data && typeof parsed.data === 'string' ? parsed.data : null);
|
||||
const isBot = uid && uid.startsWith('bot_');
|
||||
if (asrText && !isBot) {
|
||||
latestUserSpeech.set(roomId, { text: asrText, timestamp: Date.now(), source: 'room_object' });
|
||||
console.log(`[RoomMsg] ✅ Stored user speech (obj) for ${roomId}: "${asrText}"`);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[RoomMsg] Error:', error.message);
|
||||
res.json({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tool-callback', async (req, res) => {
|
||||
console.log('[ToolCallback] Legacy callback received:', JSON.stringify(req.body));
|
||||
res.json({ success: true, message: 'deprecated, use fc_callback instead' });
|
||||
});
|
||||
|
||||
router.get('/sessions', (req, res) => {
|
||||
const sessions = [];
|
||||
for (const [id, session] of activeSessions) {
|
||||
sessions.push({
|
||||
sessionId: id,
|
||||
roomId: session.roomId,
|
||||
userId: session.userId,
|
||||
duration: Math.floor((Date.now() - session.startTime) / 1000),
|
||||
subtitleCount: session.subtitles.length,
|
||||
});
|
||||
}
|
||||
res.json({ success: true, data: sessions });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
300
test2/server/services/arkChatService.js
Normal file
300
test2/server/services/arkChatService.js
Normal file
@@ -0,0 +1,300 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class ArkChatService {
|
||||
constructor() {
|
||||
this.baseUrl = 'https://ark.cn-beijing.volces.com/api/v3';
|
||||
}
|
||||
|
||||
_getAuth() {
|
||||
return process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||||
}
|
||||
|
||||
_isMockMode() {
|
||||
const ep = process.env.VOLC_ARK_ENDPOINT_ID;
|
||||
return !ep || ep === 'your_ark_endpoint_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取方舟知识库配置(如果已配置)
|
||||
* @returns {object|null} 知识库 metadata 配置
|
||||
*/
|
||||
_getKnowledgeBaseConfig() {
|
||||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||||
if (!kbIds || kbIds === 'your_knowledge_base_dataset_id') return null;
|
||||
|
||||
const datasetIds = kbIds.split(',').map(id => id.trim()).filter(Boolean);
|
||||
if (datasetIds.length === 0) return null;
|
||||
|
||||
const topK = parseInt(process.env.VOLC_ARK_KNOWLEDGE_TOP_K) || 3;
|
||||
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.5;
|
||||
|
||||
return {
|
||||
dataset_ids: datasetIds,
|
||||
top_k: topK,
|
||||
threshold: threshold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式调用方舟 LLM
|
||||
*/
|
||||
async chat(messages, tools = []) {
|
||||
if (this._isMockMode()) {
|
||||
console.warn('[ArkChat] EndPointId not configured, returning mock response');
|
||||
return this._mockChat(messages);
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: process.env.VOLC_ARK_ENDPOINT_ID,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (tools.length > 0) body.tools = tools;
|
||||
|
||||
// 注入方舟私域知识库配置
|
||||
const kbConfig = this._getKnowledgeBaseConfig();
|
||||
if (kbConfig) {
|
||||
body.metadata = { knowledge_base: kbConfig };
|
||||
console.log('[ArkChat] Knowledge base enabled:', kbConfig.dataset_ids);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${this.baseUrl}/chat/completions`, body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this._getAuth()}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const choice = response.data.choices?.[0];
|
||||
if (!choice) throw new Error('No response from Ark LLM');
|
||||
|
||||
const msg = choice.message;
|
||||
return {
|
||||
content: msg.content || '',
|
||||
toolCalls: msg.tool_calls || null,
|
||||
finishReason: choice.finish_reason,
|
||||
usage: response.data.usage,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
console.error('[ArkChat] API error:', error.response.status, error.response.data);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式调用方舟 LLM,通过回调逐块输出
|
||||
* @param {Array} messages
|
||||
* @param {Array} tools
|
||||
* @param {function} onChunk - (text: string) => void
|
||||
* @param {function} onToolCall - (toolCalls: Array) => void
|
||||
* @param {function} onDone - (fullContent: string) => void
|
||||
*/
|
||||
async chatStream(messages, tools = [], { onChunk, onToolCall, onDone }) {
|
||||
if (this._isMockMode()) {
|
||||
return this._mockChatStream(messages, { onChunk, onDone });
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: process.env.VOLC_ARK_ENDPOINT_ID,
|
||||
messages,
|
||||
stream: true,
|
||||
};
|
||||
if (tools.length > 0) body.tools = tools;
|
||||
|
||||
// 注入方舟私域知识库配置
|
||||
const kbConfig = this._getKnowledgeBaseConfig();
|
||||
if (kbConfig) {
|
||||
body.metadata = { knowledge_base: kbConfig };
|
||||
}
|
||||
|
||||
const response = await axios.post(`${this.baseUrl}/chat/completions`, body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this._getAuth()}`,
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let fullContent = '';
|
||||
let toolCalls = [];
|
||||
let buffer = '';
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
||||
const data = trimmed.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
if (delta.content) {
|
||||
fullContent += delta.content;
|
||||
onChunk?.(delta.content);
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (!toolCalls[tc.index]) {
|
||||
toolCalls[tc.index] = { id: tc.id, type: tc.type, function: { name: '', arguments: '' } };
|
||||
}
|
||||
if (tc.function?.name) toolCalls[tc.index].function.name += tc.function.name;
|
||||
if (tc.function?.arguments) toolCalls[tc.index].function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (toolCalls.length > 0) {
|
||||
onToolCall?.(toolCalls);
|
||||
}
|
||||
onDone?.(fullContent);
|
||||
resolve({ content: fullContent, toolCalls: toolCalls.length > 0 ? toolCalls : null });
|
||||
});
|
||||
|
||||
response.data.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理包含工具调用的完整对话循环(非流式)
|
||||
*/
|
||||
async chatWithTools(messages, tools, toolExecutor) {
|
||||
const result = await this.chat(messages, tools);
|
||||
|
||||
if (!result.toolCalls || result.toolCalls.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const updatedMessages = [
|
||||
...messages,
|
||||
{ role: 'assistant', content: result.content, tool_calls: result.toolCalls },
|
||||
];
|
||||
|
||||
for (const tc of result.toolCalls) {
|
||||
const args = typeof tc.function.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments)
|
||||
: tc.function.arguments;
|
||||
|
||||
const toolResult = await toolExecutor.execute(tc.function.name, args, messages);
|
||||
|
||||
updatedMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: tc.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
|
||||
const finalResult = await this.chat(updatedMessages, tools);
|
||||
return {
|
||||
...finalResult,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式版工具调用循环:先流式输出,如遇工具调用则执行后再流式输出最终结果
|
||||
*/
|
||||
async chatStreamWithTools(messages, tools, toolExecutor, { onChunk, onToolCall, onDone }) {
|
||||
const result = await this.chatStream(messages, tools, {
|
||||
onChunk,
|
||||
onToolCall,
|
||||
onDone: () => {}, // don't fire onDone yet
|
||||
});
|
||||
|
||||
if (!result.toolCalls || result.toolCalls.length === 0) {
|
||||
onDone?.(result.content);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 通知前端正在调用工具
|
||||
onToolCall?.(result.toolCalls);
|
||||
|
||||
const updatedMessages = [
|
||||
...messages,
|
||||
{ role: 'assistant', content: result.content, tool_calls: result.toolCalls },
|
||||
];
|
||||
|
||||
for (const tc of result.toolCalls) {
|
||||
const args = typeof tc.function.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments)
|
||||
: tc.function.arguments;
|
||||
|
||||
const toolResult = await toolExecutor.execute(tc.function.name, args, messages);
|
||||
updatedMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: tc.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
|
||||
// 工具执行完后,流式输出最终回答
|
||||
const finalResult = await this.chatStream(updatedMessages, tools, { onChunk, onToolCall: null, onDone });
|
||||
return {
|
||||
...finalResult,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
_mockChat(messages) {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const userText = lastMsg?.content || '';
|
||||
console.log(`[ArkChat][MOCK] User: ${userText}`);
|
||||
|
||||
return {
|
||||
content: this._getMockReply(userText),
|
||||
toolCalls: null,
|
||||
finishReason: 'stop',
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async _mockChatStream(messages, { onChunk, onDone }) {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const userText = lastMsg?.content || '';
|
||||
console.log(`[ArkChat][MOCK-STREAM] User: ${userText}`);
|
||||
|
||||
const reply = this._getMockReply(userText);
|
||||
// 模拟逐字输出
|
||||
for (let i = 0; i < reply.length; i++) {
|
||||
onChunk?.(reply[i]);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
onDone?.(reply);
|
||||
return { content: reply, toolCalls: null };
|
||||
}
|
||||
|
||||
_getMockReply(userText) {
|
||||
if (userText.includes('天气')) {
|
||||
return '根据 query_weather 工具查询,北京今天晴,气温 22°C,湿度 45%,北风3级。适合外出活动!';
|
||||
}
|
||||
if (userText.includes('订单')) {
|
||||
return '通过 query_order 工具查询,您的订单(ID: 12345)当前状态为:已发货,预计明天送达。快递单号:SF1234567890。';
|
||||
}
|
||||
if (userText.includes('你好') || userText.includes('嗨') || userText.includes('hi')) {
|
||||
return '你好!我是小智,很高兴为你服务。有什么我可以帮你的吗?';
|
||||
}
|
||||
if (userText.includes('知识') || userText.includes('退货') || userText.includes('政策')) {
|
||||
return '根据知识库查询,我们的退货政策如下:自签收之日起7天内可无理由退货,15天内可换货。请保持商品及包装完好。如需退货,请在"我的订单"中提交退货申请。';
|
||||
}
|
||||
return `收到你的消息:"${userText}"。当前为模拟模式,配置方舟 LLM 凭证后将接入真实 AI 模型。你可以试试问我天气、订单、退货政策等问题。`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ArkChatService();
|
||||
211
test2/server/services/cozeChatService.js
Normal file
211
test2/server/services/cozeChatService.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Coze 智能体对话服务
|
||||
* 通过 Coze v3 Chat API 与已配置知识库的 Bot 进行对话
|
||||
* 支持流式和非流式两种模式,Coze 内部管理会话历史
|
||||
*/
|
||||
class CozeChatService {
|
||||
constructor() {
|
||||
this.baseUrl = (process.env.COZE_BASE_URL || 'https://api.coze.cn') + '/v3';
|
||||
}
|
||||
|
||||
_getHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${process.env.COZE_API_TOKEN}`,
|
||||
};
|
||||
}
|
||||
|
||||
_getBotId() {
|
||||
return process.env.COZE_BOT_ID;
|
||||
}
|
||||
|
||||
isConfigured() {
|
||||
const token = process.env.COZE_API_TOKEN;
|
||||
const botId = process.env.COZE_BOT_ID;
|
||||
return token && token !== 'your_coze_api_token' && botId && botId !== 'your_coze_bot_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式对话
|
||||
* @param {string} userId - 用户标识
|
||||
* @param {string} message - 用户消息
|
||||
* @param {string|null} conversationId - Coze 会话 ID(续接对话时传入)
|
||||
* @param {Array} extraMessages - 额外上下文消息(如语音字幕历史)
|
||||
* @returns {{ content: string, conversationId: string }}
|
||||
*/
|
||||
async chat(userId, message, conversationId = null, extraMessages = []) {
|
||||
const additionalMessages = [
|
||||
...extraMessages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content || m.text,
|
||||
content_type: 'text',
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: message,
|
||||
content_type: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const body = {
|
||||
bot_id: this._getBotId(),
|
||||
user_id: userId,
|
||||
additional_messages: additionalMessages,
|
||||
stream: false,
|
||||
auto_save_history: true,
|
||||
};
|
||||
|
||||
if (conversationId) {
|
||||
body.conversation_id = conversationId;
|
||||
}
|
||||
|
||||
console.log(`[CozeChat] Sending non-stream chat, userId=${userId}, convId=${conversationId || 'new'}`);
|
||||
|
||||
const chatRes = await axios.post(`${this.baseUrl}/chat`, body, {
|
||||
headers: this._getHeaders(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const chatData = chatRes.data?.data;
|
||||
if (!chatData?.id || !chatData?.conversation_id) {
|
||||
throw new Error('Coze chat creation failed: ' + JSON.stringify(chatRes.data));
|
||||
}
|
||||
|
||||
const chatId = chatData.id;
|
||||
const convId = chatData.conversation_id;
|
||||
|
||||
// 轮询等待完成(最多 60 秒)
|
||||
const maxAttempts = 30;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
const statusRes = await axios.get(
|
||||
`${this.baseUrl}/chat/retrieve?chat_id=${chatId}&conversation_id=${convId}`,
|
||||
{ headers: this._getHeaders(), timeout: 10000 }
|
||||
);
|
||||
|
||||
const status = statusRes.data?.data?.status;
|
||||
if (status === 'completed') break;
|
||||
if (status === 'failed' || status === 'requires_action') {
|
||||
throw new Error(`Coze chat ended with status: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取消息列表
|
||||
const msgRes = await axios.get(
|
||||
`${this.baseUrl}/chat/message/list?chat_id=${chatId}&conversation_id=${convId}`,
|
||||
{ headers: this._getHeaders(), timeout: 10000 }
|
||||
);
|
||||
|
||||
const messages = msgRes.data?.data || [];
|
||||
const answerMsg = messages.find(m => m.role === 'assistant' && m.type === 'answer');
|
||||
|
||||
return {
|
||||
content: answerMsg?.content || '',
|
||||
conversationId: convId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话
|
||||
* @param {string} userId - 用户标识
|
||||
* @param {string} message - 用户消息
|
||||
* @param {string|null} conversationId - Coze 会话 ID
|
||||
* @param {Array} extraMessages - 额外上下文消息
|
||||
* @param {{ onChunk, onDone }} callbacks - 流式回调
|
||||
* @returns {{ content: string, conversationId: string }}
|
||||
*/
|
||||
async chatStream(userId, message, conversationId = null, extraMessages = [], { onChunk, onDone }) {
|
||||
const additionalMessages = [
|
||||
...extraMessages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content || m.text,
|
||||
content_type: 'text',
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: message,
|
||||
content_type: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const body = {
|
||||
bot_id: this._getBotId(),
|
||||
user_id: userId,
|
||||
additional_messages: additionalMessages,
|
||||
stream: true,
|
||||
auto_save_history: true,
|
||||
};
|
||||
|
||||
if (conversationId) {
|
||||
body.conversation_id = conversationId;
|
||||
}
|
||||
|
||||
console.log(`[CozeChat] Sending stream chat, userId=${userId}, convId=${conversationId || 'new'}`);
|
||||
|
||||
const response = await axios.post(`${this.baseUrl}/chat`, body, {
|
||||
headers: this._getHeaders(),
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let fullContent = '';
|
||||
let resultConvId = conversationId;
|
||||
let buffer = '';
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let currentEvent = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('event:')) {
|
||||
currentEvent = trimmed.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === '"[DONE]"' || data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (currentEvent === 'conversation.chat.created') {
|
||||
resultConvId = parsed.conversation_id || resultConvId;
|
||||
}
|
||||
|
||||
if (currentEvent === 'conversation.message.delta') {
|
||||
if (parsed.role === 'assistant' && parsed.type === 'answer') {
|
||||
const content = parsed.content || '';
|
||||
fullContent += content;
|
||||
onChunk?.(content);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// skip malformed SSE lines
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
onDone?.(fullContent);
|
||||
resolve({ content: fullContent, conversationId: resultConvId });
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
console.error('[CozeChat] Stream error:', err.message);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CozeChatService();
|
||||
327
test2/server/services/toolExecutor.js
Normal file
327
test2/server/services/toolExecutor.js
Normal file
@@ -0,0 +1,327 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class ToolExecutor {
|
||||
static async execute(toolName, args, context = []) {
|
||||
const startTime = Date.now();
|
||||
console.log(`[ToolExecutor] Executing: ${toolName}`, args);
|
||||
|
||||
const handlers = {
|
||||
query_weather: this.queryWeather,
|
||||
query_order: this.queryOrder,
|
||||
search_knowledge: this.searchKnowledge,
|
||||
get_current_time: this.getCurrentTime,
|
||||
calculate: this.calculate,
|
||||
};
|
||||
|
||||
const handler = handlers[toolName];
|
||||
if (!handler) {
|
||||
console.warn(`[ToolExecutor] Unknown tool: ${toolName}`);
|
||||
return { error: `未知的工具: ${toolName}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler.call(this, args, context);
|
||||
const ms = Date.now() - startTime;
|
||||
console.log(`[ToolExecutor] ${toolName} completed in ${ms}ms:`, JSON.stringify(result).substring(0, 200));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[ToolExecutor] ${toolName} error:`, error);
|
||||
return { error: `工具执行失败: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
static async queryWeather({ city }) {
|
||||
const mockData = {
|
||||
'北京': { temp: '22°C', weather: '晴', humidity: '45%', wind: '北风3级', aqi: 65, tips: '空气质量良好,适合户外活动' },
|
||||
'上海': { temp: '26°C', weather: '多云', humidity: '72%', wind: '东南风2级', aqi: 78, tips: '注意防晒' },
|
||||
'广州': { temp: '30°C', weather: '阵雨', humidity: '85%', wind: '南风1级', aqi: 55, tips: '记得带伞' },
|
||||
'深圳': { temp: '29°C', weather: '多云', humidity: '80%', wind: '东风2级', aqi: 60, tips: '较为闷热,注意防暑' },
|
||||
'杭州': { temp: '24°C', weather: '晴', humidity: '55%', wind: '西北风2级', aqi: 50, tips: '天气宜人' },
|
||||
'成都': { temp: '20°C', weather: '阴', humidity: '70%', wind: '微风', aqi: 85, tips: '天气阴沉,适合室内活动' },
|
||||
'武汉': { temp: '25°C', weather: '晴', humidity: '60%', wind: '东风3级', aqi: 72, tips: '适合出行' },
|
||||
'南京': { temp: '23°C', weather: '多云', humidity: '58%', wind: '东北风2级', aqi: 68, tips: '温度适宜' },
|
||||
'西安': { temp: '18°C', weather: '晴', humidity: '35%', wind: '西北风3级', aqi: 90, tips: '天气干燥,注意补水' },
|
||||
'重庆': { temp: '27°C', weather: '阴转多云', humidity: '75%', wind: '微风', aqi: 80, tips: '注意防潮' },
|
||||
};
|
||||
|
||||
const data = mockData[city];
|
||||
if (data) {
|
||||
return { city, date: new Date().toLocaleDateString('zh-CN'), ...data };
|
||||
}
|
||||
// 对未知城市生成随机数据
|
||||
const weathers = ['晴', '多云', '阴', '小雨', '大风'];
|
||||
return {
|
||||
city,
|
||||
date: new Date().toLocaleDateString('zh-CN'),
|
||||
temp: `${Math.floor(Math.random() * 20 + 10)}°C`,
|
||||
weather: weathers[Math.floor(Math.random() * weathers.length)],
|
||||
humidity: `${Math.floor(Math.random() * 50 + 30)}%`,
|
||||
wind: '微风',
|
||||
aqi: Math.floor(Math.random() * 100 + 30),
|
||||
tips: '数据仅供参考',
|
||||
};
|
||||
}
|
||||
|
||||
static async queryOrder({ order_id }) {
|
||||
const statuses = ['待支付', '已支付', '拣货中', '已发货', '运输中', '已签收'];
|
||||
const hash = order_id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||
const statusIdx = hash % statuses.length;
|
||||
|
||||
return {
|
||||
order_id,
|
||||
status: statuses[statusIdx],
|
||||
estimated_delivery: '2026-03-01',
|
||||
tracking_number: 'SF' + order_id.replace(/\D/g, '').padEnd(10, '0').substring(0, 10),
|
||||
items: [
|
||||
{ name: '智能音箱 Pro', quantity: 1, price: '¥299' },
|
||||
],
|
||||
create_time: '2026-02-20 14:30:00',
|
||||
};
|
||||
}
|
||||
|
||||
static async searchKnowledge({ query } = {}, context = []) {
|
||||
const startTime = Date.now();
|
||||
query = query || '';
|
||||
console.log(`[ToolExecutor] searchKnowledge called with query="${query}"`);
|
||||
|
||||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||||
if (kbIds && kbIds !== 'your_knowledge_base_dataset_id') {
|
||||
try {
|
||||
console.log('[ToolExecutor] Trying Ark Knowledge Search...');
|
||||
const result = await this.searchArkKnowledge(query, context);
|
||||
console.log(`[ToolExecutor] Ark KB search succeeded in ${Date.now() - startTime}ms`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('[ToolExecutor] Ark Knowledge Search failed:', error.message);
|
||||
console.log('[ToolExecutor] Falling back to local Knowledge Base');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log('[ToolExecutor] Using local Knowledge Base (voice fast path)');
|
||||
const result = this.searchLocalKnowledge(query);
|
||||
console.log(`[ToolExecutor] Local KB search completed in ${Date.now() - startTime}ms`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过方舟 Chat Completions API + knowledge_base metadata 进行知识检索
|
||||
* 使用独立的 LLM 调用,专门用于知识库检索场景(如语音通话的工具回调)
|
||||
*/
|
||||
static async searchArkKnowledge(query, context = []) {
|
||||
const endpointId = process.env.VOLC_ARK_ENDPOINT_ID;
|
||||
const authKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||||
|
||||
const datasetIds = kbIds.split(',').map(id => id.trim()).filter(Boolean);
|
||||
const topK = parseInt(process.env.VOLC_ARK_KNOWLEDGE_TOP_K) || 3;
|
||||
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.5;
|
||||
|
||||
// 当 query 为空时(FC 流式 chunks 乱序无法解析),使用简短的默认查询
|
||||
const effectiveQuery = (query && query.trim()) ? query : '请介绍你们的产品和服务';
|
||||
if (!query || !query.trim()) {
|
||||
console.log('[ToolExecutor] Empty query, using default: "' + effectiveQuery + '"');
|
||||
}
|
||||
|
||||
// 提取最近 3 轮对话作为上下文(最多 6 条 user/assistant 消息)
|
||||
const recentContext = context
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(-6);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个知识库检索助手。请根据知识库中的内容回答用户问题。如果知识库中没有相关内容,请如实说明。回答时请引用知识库来源。',
|
||||
},
|
||||
...recentContext,
|
||||
{
|
||||
role: 'user',
|
||||
content: effectiveQuery,
|
||||
},
|
||||
];
|
||||
|
||||
if (recentContext.length > 0) {
|
||||
console.log(`[ToolExecutor] Ark KB search with ${recentContext.length} context messages`);
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: endpointId,
|
||||
messages,
|
||||
metadata: {
|
||||
knowledge_base: {
|
||||
dataset_ids: datasetIds,
|
||||
top_k: topK,
|
||||
threshold: threshold,
|
||||
},
|
||||
},
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authKey}`,
|
||||
},
|
||||
timeout: 15000, // 方舟知识库超时 15s(减少等待,防止 LLM 重试风暴)
|
||||
}
|
||||
);
|
||||
|
||||
const choice = response.data.choices?.[0];
|
||||
const content = choice?.message?.content || '未找到相关信息';
|
||||
|
||||
return {
|
||||
query,
|
||||
results: [{
|
||||
title: '方舟知识库检索结果',
|
||||
content: content,
|
||||
}],
|
||||
total: 1,
|
||||
source: 'ark_knowledge',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Coze v3 Chat API 进行知识库检索
|
||||
* 需要在 Coze 平台创建 Bot 并挂载知识库插件
|
||||
*/
|
||||
static async searchCozeKnowledge(query) {
|
||||
const apiToken = process.env.COZE_API_TOKEN;
|
||||
const botId = process.env.COZE_BOT_ID;
|
||||
const baseUrl = 'https://api.coze.cn/v3';
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
};
|
||||
|
||||
// 1. 创建对话
|
||||
const chatRes = await axios.post(`${baseUrl}/chat`, {
|
||||
bot_id: botId,
|
||||
user_id: 'kb_search_user',
|
||||
additional_messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: query,
|
||||
content_type: 'text',
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
auto_save_history: false,
|
||||
}, { headers, timeout: 15000 });
|
||||
|
||||
const chatData = chatRes.data?.data;
|
||||
if (!chatData?.id || !chatData?.conversation_id) {
|
||||
throw new Error('Coze chat creation failed: ' + JSON.stringify(chatRes.data));
|
||||
}
|
||||
|
||||
const chatId = chatData.id;
|
||||
const conversationId = chatData.conversation_id;
|
||||
|
||||
// 2. 轮询等待完成(最多 30 秒)
|
||||
const maxAttempts = 15;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
const statusRes = await axios.get(
|
||||
`${baseUrl}/chat/retrieve?chat_id=${chatId}&conversation_id=${conversationId}`,
|
||||
{ headers, timeout: 10000 }
|
||||
);
|
||||
|
||||
const status = statusRes.data?.data?.status;
|
||||
if (status === 'completed') break;
|
||||
if (status === 'failed' || status === 'requires_action') {
|
||||
throw new Error(`Coze chat ended with status: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取消息列表
|
||||
const msgRes = await axios.get(
|
||||
`${baseUrl}/chat/message/list?chat_id=${chatId}&conversation_id=${conversationId}`,
|
||||
{ headers, timeout: 10000 }
|
||||
);
|
||||
|
||||
const messages = msgRes.data?.data || [];
|
||||
const answerMsg = messages.find(m => m.role === 'assistant' && m.type === 'answer');
|
||||
const content = answerMsg?.content || '未找到相关信息';
|
||||
|
||||
return {
|
||||
query,
|
||||
results: [{
|
||||
title: 'Coze 知识库检索结果',
|
||||
content: content,
|
||||
}],
|
||||
total: 1,
|
||||
source: 'coze',
|
||||
};
|
||||
}
|
||||
|
||||
static async searchLocalKnowledge(query) {
|
||||
const knowledgeBase = {
|
||||
'退货': {
|
||||
title: '退货政策',
|
||||
content: '自签收之日起7天内可无理由退货,15天内可换货。请保持商品及包装完好。退货运费由买家承担(质量问题除外)。',
|
||||
},
|
||||
'退款': {
|
||||
title: '退款流程',
|
||||
content: '退货审核通过后,退款将在3-5个工作日内原路返回。如超过时间未到账,请联系客服。',
|
||||
},
|
||||
'配送': {
|
||||
title: '配送说明',
|
||||
content: '默认顺丰快递,普通订单1-3天送达,偏远地区3-7天。满99元免运费。',
|
||||
},
|
||||
'保修': {
|
||||
title: '保修政策',
|
||||
content: '电子产品保修期1年,自购买之日起计算。人为损坏不在保修范围内。',
|
||||
},
|
||||
'会员': {
|
||||
title: '会员权益',
|
||||
content: '会员享受9折优惠、免运费、专属客服、生日礼券等权益。年费128元。',
|
||||
},
|
||||
};
|
||||
|
||||
const results = [];
|
||||
const q = query || '';
|
||||
for (const [key, value] of Object.entries(knowledgeBase)) {
|
||||
if (q.includes(key) || key.includes(q)) {
|
||||
results.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
results.push({
|
||||
title: '搜索结果',
|
||||
content: `未找到与"${query}"直接相关的知识库文档。建议联系人工客服获取更详细的帮助。`,
|
||||
});
|
||||
}
|
||||
|
||||
return { query, results, total: results.length, source: 'local' };
|
||||
}
|
||||
|
||||
static async getCurrentTime() {
|
||||
const now = new Date();
|
||||
return {
|
||||
datetime: now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||||
timestamp: now.getTime(),
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekday: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.getDay()],
|
||||
};
|
||||
}
|
||||
|
||||
static async calculate({ expression }) {
|
||||
try {
|
||||
// 仅允许数字和基本运算符,防止注入
|
||||
const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, '');
|
||||
if (!sanitized || sanitized !== expression.replace(/\s/g, '')) {
|
||||
return { error: '表达式包含不支持的字符', expression };
|
||||
}
|
||||
const result = Function('"use strict"; return (' + sanitized + ')')();
|
||||
return { expression, result: Number(result), formatted: String(result) };
|
||||
} catch (e) {
|
||||
return { error: '计算失败: ' + e.message, expression };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ToolExecutor;
|
||||
132
test2/server/services/volcengine.js
Normal file
132
test2/server/services/volcengine.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const { Signer } = require('@volcengine/openapi');
|
||||
const fetch = require('node-fetch');
|
||||
const { AccessToken, privileges } = require('../lib/token');
|
||||
|
||||
class VolcengineService {
|
||||
constructor() {
|
||||
this.baseUrl = 'https://rtc.volcengineapi.com';
|
||||
this.service = 'rtc';
|
||||
this.region = 'cn-north-1';
|
||||
this.version = '2024-12-01';
|
||||
}
|
||||
|
||||
async startVoiceChat(config) {
|
||||
console.log('[Volcengine] Starting voice chat (S2S端到端 + LLM混合, API v2024-12-01)');
|
||||
console.log('[Volcengine] RoomId:', config.RoomId);
|
||||
// ProviderParams 可能是 JSON 字符串或对象
|
||||
let pp = config.Config.S2SConfig?.ProviderParams;
|
||||
if (typeof pp === 'string') {
|
||||
try { pp = JSON.parse(pp); } catch (e) { pp = {}; }
|
||||
}
|
||||
console.log('[Volcengine] S2S AppId:', pp?.app?.appid);
|
||||
console.log('[Volcengine] S2S model:', pp?.dialog?.extra?.model);
|
||||
console.log('[Volcengine] S2S speaker:', pp?.tts?.speaker);
|
||||
console.log('[Volcengine] ProviderParams type:', typeof config.Config.S2SConfig?.ProviderParams);
|
||||
console.log('[Volcengine] LLM EndPointId:', config.Config.LLMConfig?.EndPointId);
|
||||
console.log('[Volcengine] Tools:', config.Config.LLMConfig?.Tools?.length || 0);
|
||||
console.log('[Volcengine] Full request body:', JSON.stringify(config, null, 2));
|
||||
const result = await this._callOpenAPI('StartVoiceChat', config);
|
||||
console.log('[Volcengine] StartVoiceChat response:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateVoiceChat(params) {
|
||||
console.log('[Volcengine] Updating voice chat (v2024-12-01)');
|
||||
console.log('[Volcengine] UpdateVoiceChat params:', JSON.stringify(params, null, 2));
|
||||
const result = await this._callOpenAPI('UpdateVoiceChat', params);
|
||||
console.log('[Volcengine] UpdateVoiceChat response:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
}
|
||||
|
||||
async stopVoiceChat(params) {
|
||||
console.log('[Volcengine] Stopping voice chat, RoomId:', params.RoomId);
|
||||
return this._callOpenAPI('StopVoiceChat', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RTC 入房 Token
|
||||
* 使用官方 AccessToken 库:https://github.com/volcengine/rtc-aigc-demo/blob/main/Server/token.js
|
||||
*/
|
||||
generateRTCToken(roomId, userId) {
|
||||
const appId = process.env.VOLC_RTC_APP_ID;
|
||||
const appKey = process.env.VOLC_RTC_APP_KEY;
|
||||
|
||||
if (!appId || !appKey || appKey === 'your_rtc_app_key') {
|
||||
console.warn('[Volcengine] RTC AppKey not configured, returning placeholder token');
|
||||
return `placeholder_token_${roomId}_${userId}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const token = new AccessToken(appId, appKey, roomId, userId);
|
||||
const expireTime = Math.floor(Date.now() / 1000) + 24 * 3600; // 24 小时有效
|
||||
token.expireTime(expireTime);
|
||||
token.addPrivilege(privileges.PrivPublishStream, 0);
|
||||
token.addPrivilege(privileges.PrivSubscribeStream, 0);
|
||||
|
||||
const serialized = token.serialize();
|
||||
console.log(`[Volcengine] RTC Token generated for room=${roomId}, user=${userId}`);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
async _callOpenAPI(action, body, versionOverride) {
|
||||
const ak = process.env.VOLC_ACCESS_KEY_ID;
|
||||
const sk = process.env.VOLC_SECRET_ACCESS_KEY;
|
||||
const version = versionOverride || this.version;
|
||||
|
||||
if (!ak || !sk || ak === 'your_access_key_id') {
|
||||
console.warn(`[Volcengine] Credentials not configured, returning mock response for ${action}`);
|
||||
return this._mockResponse(action, body);
|
||||
}
|
||||
|
||||
// 与官方 rtc-aigc-demo 完全一致的签名方式
|
||||
const openApiRequestData = {
|
||||
region: this.region,
|
||||
method: 'POST',
|
||||
params: {
|
||||
Action: action,
|
||||
Version: version,
|
||||
},
|
||||
headers: {
|
||||
Host: 'rtc.volcengineapi.com',
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
};
|
||||
|
||||
const signer = new Signer(openApiRequestData, this.service);
|
||||
signer.addAuthorization({ accessKeyId: ak, secretKey: sk });
|
||||
|
||||
const url = `${this.baseUrl}?Action=${action}&Version=${version}`;
|
||||
console.log(`[Volcengine] ${action} calling:`, url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: openApiRequestData.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.ResponseMetadata?.Error) {
|
||||
const err = data.ResponseMetadata.Error;
|
||||
throw new Error(`${action} failed: ${err.Code} - ${err.Message}`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`[Volcengine] ${action} error:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 响应(开发阶段凭证未配置时使用)
|
||||
*/
|
||||
_mockResponse(action, params) {
|
||||
console.log(`[Volcengine][MOCK] ${action} called with:`, JSON.stringify(params, null, 2).substring(0, 500));
|
||||
return {
|
||||
ResponseMetadata: { RequestId: `mock-${Date.now()}`, Action: action },
|
||||
Result: { Message: 'Mock response - credentials not configured' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new VolcengineService();
|
||||
Reference in New Issue
Block a user