Update code
This commit is contained in:
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