346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
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,
|
||
};
|
||
}
|
||
|
||
async summarizeContextForHandoff(messages, maxRounds = 3) {
|
||
const normalizedMessages = (Array.isArray(messages) ? messages : [])
|
||
.filter((item) => item && (item.role === 'user' || item.role === 'assistant') && String(item.content || '').trim());
|
||
|
||
let startIndex = 0;
|
||
let userRounds = 0;
|
||
for (let index = normalizedMessages.length - 1; index >= 0; index -= 1) {
|
||
if (normalizedMessages[index].role === 'user') {
|
||
userRounds += 1;
|
||
startIndex = index;
|
||
if (userRounds >= Math.max(1, maxRounds)) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const recentMessages = normalizedMessages.slice(startIndex);
|
||
|
||
if (!recentMessages.length) {
|
||
return '';
|
||
}
|
||
|
||
const transcript = recentMessages
|
||
.map((item, index) => `${index + 1}. ${item.role === 'user' ? '用户' : '助手'}:${String(item.content || '').trim()}`)
|
||
.join('\n');
|
||
|
||
if (this._isMockMode()) {
|
||
const lastUserMessage = [...recentMessages].reverse().find((item) => item.role === 'user');
|
||
return lastUserMessage ? `用户当前主要在追问:${lastUserMessage.content}` : '';
|
||
}
|
||
|
||
const result = await this.chat([
|
||
{
|
||
role: 'system',
|
||
content: '你是对话交接摘要助手。请基于最近几轮对话生成一段简洁中文摘要,供另一个模型无缝接管会话。摘要必须同时包含:用户当前主要问题、已经确认的信息、仍待解决的问题。不要使用标题、项目符号或编号,不要虚构事实,控制在120字以内。',
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: `请总结以下最近${Math.ceil(recentMessages.length / 2)}轮对话:\n${transcript}`,
|
||
},
|
||
], []);
|
||
|
||
return String(result.content || '').trim();
|
||
}
|
||
|
||
/**
|
||
* 非流式调用方舟 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();
|