212 lines
6.0 KiB
JavaScript
212 lines
6.0 KiB
JavaScript
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();
|