godcrm/backend/routes/v3/ai-agents/enhancedChatController.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

317 lines
12 KiB
JavaScript

/**
* Enhanced Chat Controller
* ADR-023: Mentions, task binding, context optimization, chunking
*/
import { Router } from 'express';
import { dbGet, dbRun, dbAll } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import chatChunkingService from '../../../services/chatChunkingService.js';
import { success, notFound, badRequest, error } from '../../../utils/response.js';
import {
safeParseJSON,
getConversationMessages,
resolveConversationsTableId,
parseMentions,
} from './shared.js';
const router = Router();
/**
* POST /api/v3/ai/chat/send
* Send message with @mention support
*/
router.post('/chat/send', async (req, res) => {
try {
const userId = req.user?.id;
const { conversationId, message, agentId, mentions = [] } = req.body;
if (!message && !conversationId) return badRequest(res, 'Message or conversationId is required');
const parsedMentions = parseMentions(message);
const allMentions = [...new Set([...mentions, ...parsedMentions])];
let conversation;
if (conversationId) {
const row = await dbGet(`SELECT id, data, table_id FROM table_rows WHERE id = ?`, [conversationId]);
if (row) {
conversation = { id: row.id, tableId: row.table_id, data: safeParseJSON(row.data, {}) };
}
}
const newMessage = {
id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
role: 'user', content: message, userId, mentions: allMentions,
timestamp: new Date().toISOString()
};
if (conversation) {
const messages = getConversationMessages(conversation.data);
messages.push(newMessage);
conversation.data.messages = messages;
conversation.data.content = messages;
conversation.data.messages_count = messages.length;
conversation.data.last_message = message.substring(0, 100);
await dbRun(`UPDATE table_rows SET data = ?, updated_at = datetime('now') WHERE id = ?`,
[JSON.stringify(conversation.data), conversationId]);
}
const mentionedAgents = [];
if (allMentions.length > 0) {
const agentUsers = await dbAll(`
SELECT u.id, u.name, u.agent_config, u.managed_by_agent_row_id, tr.data as agent_data
FROM users u LEFT JOIN table_rows tr ON tr.id = u.managed_by_agent_row_id
WHERE u.user_type = 'agent'
`);
for (const mention of allMentions) {
const agent = agentUsers.find(a => {
const nameSlug = a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
return nameSlug.includes(mention) || mention.includes(nameSlug.split('-')[0]);
});
if (agent) {
mentionedAgents.push({
id: agent.id, name: agent.name, agentRowId: agent.managed_by_agent_row_id,
config: safeParseJSON(agent.agent_config, {}), agentData: safeParseJSON(agent.agent_data, {})
});
}
}
}
return success(res, {
message: newMessage, conversationId: conversation?.id,
mentionedAgents: mentionedAgents.map(a => ({
id: a.id, name: a.name,
willRespond: !a.config.respond_only_when_mentioned || allMentions.length > 0
}))
});
} catch (err) {
apiLogger.error({ err, context: 'Chat Send' }, 'Error sending chat message');
return error(res, 'CHAT_SEND_ERROR', 'Failed to send message', 500);
}
});
/**
* POST /api/v3/ai/chat/:conversationId/bind-task
*/
router.post('/chat/:conversationId/bind-task', async (req, res) => {
try {
const { conversationId } = req.params;
const { tableId, rowId } = req.body;
if (!tableId || !rowId) return badRequest(res, 'tableId and rowId are required');
const conversation = await dbGet(`SELECT id, data FROM table_rows WHERE id = ?`, [conversationId]);
if (!conversation) return notFound(res, 'Conversation not found');
const task = await dbGet(`SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`, [rowId, tableId]);
if (!task) return notFound(res, 'Task not found');
const data = safeParseJSON(conversation.data, {});
data.bound_table_id = Number(tableId);
data.bound_row_id = Number(rowId);
await dbRun(`UPDATE table_rows SET data = ?, updated_at = datetime('now') WHERE id = ?`,
[JSON.stringify(data), conversationId]);
return success(res, {
message: 'Conversation bound to task',
binding: { conversationId: Number(conversationId), tableId: Number(tableId), rowId: Number(rowId) }
});
} catch (err) {
apiLogger.error({ err, context: 'Chat Bind Task' }, 'Error binding chat to task');
return error(res, 'BIND_TASK_ERROR', 'Failed to bind task', 500);
}
});
/**
* GET /api/v3/ai/chat/:conversationId/context
*/
router.get('/chat/:conversationId/context', async (req, res) => {
try {
const { conversationId } = req.params;
const { includeRaw = false, maxMessages = 20, includeSummaries = true } = req.query;
const conversation = await dbGet(`SELECT id, data FROM table_rows WHERE id = ?`, [conversationId]);
if (!conversation) return notFound(res, 'Conversation not found');
const data = safeParseJSON(conversation.data, {});
const messages = getConversationMessages(data);
const context = {
conversationId: Number(conversationId), title: data.title,
agentId: data.agent_id, agentName: data.agent_name, totalMessages: messages.length,
activeMessages: messages.slice(-Number(maxMessages)),
boundTask: data.bound_table_id ? { tableId: data.bound_table_id, rowId: data.bound_row_id } : null
};
if (includeSummaries && data.summaries) context.summaries = data.summaries;
if (includeRaw === 'true') context.rawMessages = messages;
return success(res, { context });
} catch (err) {
apiLogger.error({ err, context: 'Chat Context' }, 'Error getting chat context');
return error(res, 'CHAT_CONTEXT_ERROR', 'Failed to get context', 500);
}
});
/**
* GET /api/v3/tasks/:tableId/:rowId/chat
*/
router.get('/tasks/:tableId/:rowId/chat', async (req, res) => {
try {
const userId = req.user?.id;
const { tableId, rowId } = req.params;
const task = await dbGet(`
SELECT tr.id, tr.data, ut.project_id, p.space_id
FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id JOIN projects p ON ut.project_id = p.id
WHERE tr.id = ? AND tr.table_id = ?
`, [rowId, tableId]);
if (!task) return notFound(res, 'Task not found');
const { tableId: conversationsTableId } = await resolveConversationsTableId({ spaceId: task.space_id });
if (!conversationsTableId) return notFound(res, 'No conversations table found. Please create AI Chat History table.');
const existingChat = await dbGet(`
SELECT id, data FROM table_rows
WHERE table_id = ? AND data->>'bound_table_id' = ? AND data->>'bound_row_id' = ?
`, [conversationsTableId, String(tableId), String(rowId)]);
if (existingChat) {
const chatData = safeParseJSON(existingChat.data, {});
return success(res, {
conversation: {
id: existingChat.id, title: chatData.title,
messages: getConversationMessages(chatData), messagesCount: chatData.messages_count || 0,
boundTask: { tableId: Number(tableId), rowId: Number(rowId) }
}
});
}
const taskData = safeParseJSON(task.data, {});
const taskTitle = taskData.title || taskData.name || `Task #${rowId}`;
const sessionId = `task-${tableId}-${rowId}-${Date.now()}`;
const conversationData = {
title: `Chat: ${taskTitle}`, user_id: String(userId), space_id: task.space_id,
session_id: sessionId, messages: [], content: [], messages_count: 0,
bound_table_id: Number(tableId), bound_row_id: Number(rowId),
created_at: new Date().toISOString()
};
const result = await dbRun(`
INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
`, [conversationsTableId, sessionId, JSON.stringify(conversationData), userId]);
return success(res, {
conversation: {
id: result.lastInsertRowid || result.lastID, title: conversationData.title,
messages: [], messagesCount: 0,
boundTask: { tableId: Number(tableId), rowId: Number(rowId) }
}
});
} catch (err) {
apiLogger.error({ err, context: 'Task Chat' }, 'Error getting task chat');
return error(res, 'TASK_CHAT_ERROR', 'Failed to get task chat', 500);
}
});
/**
* POST /api/v3/ai/chat/:conversationId/optimize-context
*/
router.post('/chat/:conversationId/optimize-context', async (req, res) => {
try {
const { conversationId } = req.params;
const { maxTokens = 4000 } = req.body;
const conversation = await dbGet(`SELECT id, data FROM table_rows WHERE id = ?`, [conversationId]);
if (!conversation) return notFound(res, 'Conversation not found');
const data = safeParseJSON(conversation.data, {});
const optimizedContext = chatChunkingService.buildOptimizedContext(data, maxTokens);
return success(res, {
context: optimizedContext,
chunking: {
chunkSize: chatChunkingService.CHUNK_SIZE,
keepRecent: chatChunkingService.KEEP_RECENT_MESSAGES,
needsSummarization: chatChunkingService.needsSummarization(data)
}
});
} catch (err) {
apiLogger.error({ err, context: 'Optimize Context' }, 'Error optimizing context');
return error(res, 'OPTIMIZE_CONTEXT_ERROR', 'Failed to optimize context', 500);
}
});
/**
* GET /api/v3/ai/chat/:conversationId/chunks
*/
router.get('/chat/:conversationId/chunks', async (req, res) => {
try {
const { conversationId } = req.params;
const conversation = await dbGet(`SELECT id, data FROM table_rows WHERE id = ?`, [conversationId]);
if (!conversation) return notFound(res, 'Conversation not found');
const data = safeParseJSON(conversation.data, {});
const messages = getConversationMessages(data);
const { chunks, recentMessages } = chatChunkingService.chunkMessages(messages);
const summaries = data.chunk_summaries || [];
const chunksWithStatus = chunks.map(chunk => ({
...chunk,
hasSummary: summaries.some(s => s.chunkId === chunk.id),
summary: summaries.find(s => s.chunkId === chunk.id)?.content
}));
return success(res, {
chunks: chunksWithStatus, recentMessages: recentMessages.length,
totalMessages: messages.length, summariesCount: summaries.length
});
} catch (err) {
apiLogger.error({ err, context: 'Chat Chunks' }, 'Error getting chat chunks');
return error(res, 'CHAT_CHUNKS_ERROR', 'Failed to get chat chunks', 500);
}
});
/**
* POST /api/v3/ai/chat/:conversationId/summarize-chunk
*/
router.post('/chat/:conversationId/summarize-chunk', async (req, res) => {
try {
const { conversationId } = req.params;
const { chunkId, summary } = req.body;
if (!chunkId) return badRequest(res, 'chunkId is required');
if (summary) {
const result = await chatChunkingService.saveChunkSummary(Number(conversationId), chunkId, summary);
return success(res, { summary: result });
}
const conversation = await dbGet(`SELECT id, data FROM table_rows WHERE id = ?`, [conversationId]);
if (!conversation) return notFound(res, 'Conversation not found');
const data = safeParseJSON(conversation.data, {});
const messages = getConversationMessages(data);
const { chunks } = chatChunkingService.chunkMessages(messages);
const chunk = chunks.find(c => c.id === chunkId);
if (!chunk) return notFound(res, 'Chunk not found');
const prompt = chatChunkingService.generateSummaryPrompt(chunk.messages);
return success(res, {
chunk: { id: chunk.id, messageCount: chunk.messageCount, startTime: chunk.startTime, endTime: chunk.endTime },
summaryPrompt: prompt
});
} catch (err) {
apiLogger.error({ err, context: 'Summarize Chunk' }, 'Error summarizing chunk');
return error(res, 'SUMMARIZE_CHUNK_ERROR', 'Failed to summarize chunk', 500);
}
});
export default router;