Files
bigwo/test2/server/routes/chat.js

353 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const router = express.Router();
const cozeChatService = require('../services/cozeChatService');
const arkChatService = require('../services/arkChatService');
const ToolExecutor = require('../services/toolExecutor');
const { shouldForceKnowledgeRoute } = require('../services/realtimeDialogRouting');
const db = require('../db');
// 存储文字对话的会话状态sessionId -> session
const chatSessions = new Map();
function normalizeAssistantText(text) {
return String(text || '')
.replace(/\r/g, ' ')
.replace(/\n{2,}/g, '。')
.replace(/\n/g, ' ')
.replace(/。{2,}/g, '。')
.replace(/([])\1+/g, '$1')
.replace(/([。!?;,])\s*([。!?;,])/g, '$2')
.replace(/\s+/g, ' ')
.trim();
}
async function loadHandoffMessages(sessionId, voiceSubtitles = []) {
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); }
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,
});
}
}
return voiceMessages;
}
async function buildChatSessionState(sessionId, voiceSubtitles = []) {
const voiceMessages = await loadHandoffMessages(sessionId, voiceSubtitles);
let handoffSummary = '';
try {
handoffSummary = await arkChatService.summarizeContextForHandoff(voiceMessages, 3);
} catch (error) {
console.warn('[Chat] summarizeContextForHandoff failed:', error.message);
}
return {
userId: `user_${sessionId.slice(0, 12)}`,
conversationId: null,
voiceMessages,
handoffSummary,
handoffSummaryUsed: false,
createdAt: Date.now(),
lastActiveAt: Date.now(),
fromVoice: voiceSubtitles.length > 0 || voiceMessages.length > 0,
};
}
function buildInitialContextMessages(session) {
const summary = String(session?.handoffSummary || '').trim();
const extraMessages = [];
if (summary && !session?.handoffSummaryUsed) {
extraMessages.push({ role: 'assistant', content: `会话交接摘要:${summary}` });
}
if (Array.isArray(session?.voiceMessages) && session.voiceMessages.length > 0) {
extraMessages.push(...session.voiceMessages.slice(-6));
}
return extraMessages;
}
async function buildKnowledgeContextMessages(sessionId, session) {
const dbHistory = await db.getHistoryForLLM(sessionId, 20).catch(() => []);
const summary = String(session?.handoffSummary || '').trim();
if (!summary || session?.handoffSummaryUsed) {
return dbHistory;
}
return [
{ role: 'assistant', content: `会话交接摘要:${summary}` },
...dbHistory,
];
}
function extractKnowledgeReply(result) {
if (result && result.results && Array.isArray(result.results)) {
return result.results.map((item) => item.content || JSON.stringify(item)).join('\n');
}
if (result && result.error) {
return result.error;
}
return typeof result === 'string' ? result : '';
}
async function tryKnowledgeReply(sessionId, session, message) {
const text = String(message || '').trim();
if (!text) return null;
const context = await buildKnowledgeContextMessages(sessionId, session);
if (!shouldForceKnowledgeRoute(text, context)) {
return null;
}
const result = await ToolExecutor.execute('search_knowledge', { query: text }, context);
const content = normalizeAssistantText(extractKnowledgeReply(result));
if (!content) {
return null;
}
return {
content,
meta: {
route: 'search_knowledge',
original_text: text,
tool_name: 'search_knowledge',
tool_args: { query: text },
source: result?.source || null,
original_query: result?.original_query || text,
rewritten_query: result?.rewritten_query || null,
hit: typeof result?.hit === 'boolean' ? result.hit : null,
reason: result?.reason || null,
error_type: result?.errorType || null,
latency_ms: result?.latency_ms || null,
},
};
}
/**
* 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' });
}
const sessionState = await buildChatSessionState(sessionId, voiceSubtitles);
// 更新数据库会话模式为 chat
try { await db.createSession(sessionId, `user_${sessionId.slice(0, 12)}`, 'chat'); } catch (e) {}
chatSessions.set(sessionId, sessionState);
console.log(`[Chat] Session started: ${sessionId}, fromVoice: ${sessionState.fromVoice}, voiceMessages: ${sessionState.voiceMessages.length}, summary: ${sessionState.handoffSummary ? 'yes' : 'no'}`);
res.json({
success: true,
data: {
sessionId,
messageCount: sessionState.voiceMessages.length,
fromVoice: sessionState.fromVoice,
},
});
});
/**
* 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 = await buildChatSessionState(sessionId, []);
chatSessions.set(sessionId, session);
}
session.lastActiveAt = Date.now();
console.log(`[Chat] User(${sessionId}): ${message}`);
// 写入数据库:用户消息
db.addMessage(sessionId, 'user', message, 'chat_user').catch(e => console.warn('[DB] addMessage failed:', e.message));
const knowledgeReply = await tryKnowledgeReply(sessionId, session, message);
if (knowledgeReply) {
session.handoffSummaryUsed = true;
db.addMessage(sessionId, 'assistant', knowledgeReply.content, 'chat_bot', 'search_knowledge', knowledgeReply.meta).catch(e => console.warn('[DB] addMessage failed:', e.message));
return res.json({
success: true,
data: {
content: knowledgeReply.content,
},
});
}
// 首次对话时注入语音历史作为上下文,之后 Coze 自动管理会话历史
const extraMessages = !session.conversationId ? buildInitialContextMessages(session) : [];
const result = await cozeChatService.chat(
session.userId,
message,
session.conversationId,
extraMessages
);
const normalizedContent = normalizeAssistantText(result.content);
// 保存 Coze 返回的 conversationId
session.conversationId = result.conversationId;
session.handoffSummaryUsed = true;
console.log(`[Chat] Assistant(${sessionId}): ${normalizedContent?.substring(0, 100)}`);
// 写入数据库AI 回复
if (normalizedContent) {
db.addMessage(sessionId, 'assistant', normalizedContent, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message));
}
res.json({
success: true,
data: {
content: normalizedContent,
},
});
} 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 = await buildChatSessionState(sessionId, []);
chatSessions.set(sessionId, session);
}
session.lastActiveAt = Date.now();
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();
const knowledgeReply = await tryKnowledgeReply(sessionId, session, message);
if (knowledgeReply) {
session.handoffSummaryUsed = true;
db.addMessage(sessionId, 'assistant', knowledgeReply.content, 'chat_bot', 'search_knowledge', knowledgeReply.meta).catch(e => console.warn('[DB] addMessage failed:', e.message));
res.write(`data: ${JSON.stringify({ type: 'done', content: knowledgeReply.content })}\n\n`);
return res.end();
}
try {
// 首次对话时注入语音历史作为上下文
const extraMessages = !session.conversationId ? buildInitialContextMessages(session) : [];
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: () => {},
}
);
const normalizedContent = normalizeAssistantText(result.content);
// 保存 Coze 返回的 conversationId
session.conversationId = result.conversationId;
session.handoffSummaryUsed = true;
console.log(`[Chat][SSE] Assistant(${sessionId}): ${normalizedContent?.substring(0, 100)}`);
// 写入数据库AI 回复
if (normalizedContent) {
db.addMessage(sessionId, 'assistant', normalizedContent, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message));
}
res.write(`data: ${JSON.stringify({ type: 'done', content: normalizedContent })}\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.lastActiveAt || session.createdAt) > TTL) {
chatSessions.delete(id);
console.log(`[Chat] Session expired and cleaned: ${id}`);
}
}
}, 5 * 60 * 1000);
module.exports = router;