Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
317 lines
12 KiB
JavaScript
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;
|