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();
|