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