532 lines
20 KiB
TypeScript
532 lines
20 KiB
TypeScript
import type { Express, Request, Response } from "express";
|
|
import OpenAI from "openai";
|
|
import { chatStorage } from "./storage";
|
|
import { buildPromptWithContext } from "./prompt";
|
|
import { compassStorage } from "../../compass/storage";
|
|
import { PDFParse } from "pdf-parse";
|
|
import { learningService } from "../../learning/service";
|
|
|
|
const openai = new OpenAI({
|
|
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
|
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
|
});
|
|
|
|
export function registerChatRoutes(app: Express): void {
|
|
app.get("/api/conversations", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
const userId = req.user!.id;
|
|
const conversations = await chatStorage.getUserConversations(userId);
|
|
res.json(conversations);
|
|
} catch (error) {
|
|
console.error("Error fetching conversations:", error);
|
|
res.status(500).json({ error: "Failed to fetch conversations" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/conversations/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
const id = parseInt(req.params.id);
|
|
const userId = req.user!.id;
|
|
const conversation = await chatStorage.getConversation(id);
|
|
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
|
|
if (conversation.userId !== userId) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
const messages = await chatStorage.getMessagesByConversation(id);
|
|
res.json({ ...conversation, messages });
|
|
} catch (error) {
|
|
console.error("Error fetching conversation:", error);
|
|
res.status(500).json({ error: "Failed to fetch conversation" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/conversations", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
const userId = req.user!.id;
|
|
const { title } = req.body;
|
|
const conversation = await chatStorage.createConversation(userId, title || "Nova Conversa");
|
|
res.status(201).json(conversation);
|
|
} catch (error) {
|
|
console.error("Error creating conversation:", error);
|
|
res.status(500).json({ error: "Failed to create conversation" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/conversations/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
const id = parseInt(req.params.id);
|
|
const userId = req.user!.id;
|
|
const { title } = req.body;
|
|
|
|
if (!title || typeof title !== "string" || title.trim().length === 0) {
|
|
return res.status(400).json({ error: "Title is required" });
|
|
}
|
|
|
|
const conversation = await chatStorage.getConversation(id);
|
|
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
|
|
if (conversation.userId !== userId) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
const updated = await chatStorage.updateConversation(id, title.trim());
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating conversation:", error);
|
|
res.status(500).json({ error: "Failed to update conversation" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/conversations/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
const id = parseInt(req.params.id);
|
|
const userId = req.user!.id;
|
|
const conversation = await chatStorage.getConversation(id);
|
|
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
|
|
if (conversation.userId !== userId) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
await chatStorage.deleteConversation(id);
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
console.error("Error deleting conversation:", error);
|
|
res.status(500).json({ error: "Failed to delete conversation" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/conversations/:id/messages", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
const conversationId = parseInt(req.params.id);
|
|
const userId = req.user!.id;
|
|
const { content, fileContent, fileName, fileBase64, projectId, tenantId } = req.body;
|
|
|
|
const conversation = await chatStorage.getConversation(conversationId);
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
if (conversation.userId !== userId) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
let processedFileContent = fileContent;
|
|
|
|
if (fileName && fileBase64 && fileName.toLowerCase().endsWith('.pdf')) {
|
|
try {
|
|
const pdfBuffer = Buffer.from(fileBase64, 'base64');
|
|
const parser = new PDFParse({ data: pdfBuffer });
|
|
const result = await parser.getText();
|
|
processedFileContent = result.text;
|
|
await parser.destroy();
|
|
} catch (pdfError) {
|
|
console.error("Error parsing PDF:", pdfError);
|
|
processedFileContent = "[Erro ao processar PDF - arquivo pode estar corrompido ou protegido]";
|
|
}
|
|
}
|
|
|
|
let messageContent = content;
|
|
if (fileName && (processedFileContent || fileContent)) {
|
|
messageContent = `[Arquivo anexado: ${fileName}]\n\n${content}`;
|
|
}
|
|
|
|
const userMessage = await chatStorage.createMessage(conversationId, "user", messageContent);
|
|
|
|
if (fileName && (processedFileContent || fileContent)) {
|
|
await chatStorage.createAttachment(userMessage.id, fileName, "text", processedFileContent || fileContent);
|
|
}
|
|
|
|
const knowledgeResults = await chatStorage.searchKnowledgeBase(content);
|
|
let knowledgeContext = "";
|
|
if (knowledgeResults.length > 0) {
|
|
knowledgeContext = knowledgeResults.map(kb =>
|
|
`---\nDocumento: ${kb.title}\nAutor: ${kb.author}\nCategoria: ${kb.category}\nConteúdo: ${kb.content}\n---`
|
|
).join("\n\n");
|
|
}
|
|
|
|
let diagnosticContext: any = undefined;
|
|
|
|
const diagnosticKeywords = ['canvas', 'swot', 'pdca', 'processo', 'requisito', 'diagnóstico', 'análise', 'forças', 'fraquezas', 'oportunidades', 'ameaças', 'melhoria', 'ciclo', 'modelo de negócio', 'bmca', 'proposta de valor'];
|
|
const shouldFetchDiagnostics = diagnosticKeywords.some(keyword =>
|
|
content.toLowerCase().includes(keyword)
|
|
);
|
|
|
|
if (shouldFetchDiagnostics && tenantId) {
|
|
try {
|
|
const isMember = await compassStorage.isUserInTenant(userId, tenantId);
|
|
|
|
if (isMember) {
|
|
let validProjectId: number | undefined = undefined;
|
|
|
|
if (projectId) {
|
|
const project = await compassStorage.getProject(projectId, tenantId);
|
|
if (project) {
|
|
validProjectId = projectId;
|
|
}
|
|
}
|
|
|
|
diagnosticContext = {};
|
|
|
|
const tenant = await compassStorage.getTenant(tenantId);
|
|
if (tenant) {
|
|
diagnosticContext.tenantName = tenant.name;
|
|
}
|
|
|
|
if (validProjectId) {
|
|
const project = await compassStorage.getProject(validProjectId, tenantId);
|
|
if (project) {
|
|
diagnosticContext.projectName = project.name;
|
|
}
|
|
|
|
const canvasBlocks = await compassStorage.getCanvasBlocks(validProjectId);
|
|
if (canvasBlocks.length > 0) {
|
|
diagnosticContext.canvas = canvasBlocks;
|
|
}
|
|
|
|
const swotAnalyses = await compassStorage.getSwotAnalyses(validProjectId);
|
|
if (swotAnalyses.length > 0) {
|
|
const allSwotItems: any[] = [];
|
|
for (const analysis of swotAnalyses) {
|
|
const items = await compassStorage.getSwotItems(analysis.id);
|
|
allSwotItems.push(...items);
|
|
}
|
|
diagnosticContext.swot = { analyses: swotAnalyses, items: allSwotItems };
|
|
}
|
|
|
|
const processes = await compassStorage.getProcesses(validProjectId);
|
|
if (processes.length > 0) {
|
|
const allSteps: any[] = [];
|
|
for (const process of processes) {
|
|
const steps = await compassStorage.getProcessSteps(process.id);
|
|
allSteps.push(...steps);
|
|
}
|
|
diagnosticContext.processes = { processes, steps: allSteps };
|
|
}
|
|
}
|
|
|
|
const pdcaCycles = await compassStorage.getPdcaCycles(tenantId, validProjectId);
|
|
if (pdcaCycles.length > 0) {
|
|
const allActions: any[] = [];
|
|
for (const cycle of pdcaCycles) {
|
|
const actions = await compassStorage.getPdcaActions(cycle.id);
|
|
allActions.push(...actions);
|
|
}
|
|
diagnosticContext.pdca = { cycles: pdcaCycles, actions: allActions };
|
|
}
|
|
|
|
const requirements = await compassStorage.getRequirements(tenantId, validProjectId);
|
|
if (requirements.length > 0) {
|
|
diagnosticContext.requirements = requirements;
|
|
}
|
|
|
|
if (Object.keys(diagnosticContext).filter(k => k !== 'tenantName' && k !== 'projectName').length === 0) {
|
|
diagnosticContext = undefined;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Error fetching diagnostic context:", e);
|
|
}
|
|
}
|
|
|
|
const systemPrompt = buildPromptWithContext(knowledgeContext, processedFileContent || fileContent, diagnosticContext);
|
|
|
|
const messages = await chatStorage.getMessagesByConversation(conversationId);
|
|
const chatMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
{ role: "system", content: systemPrompt },
|
|
...messages.map((m) => ({
|
|
role: m.role as "user" | "assistant",
|
|
content: m.content,
|
|
})),
|
|
];
|
|
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
|
|
const stream = await openai.chat.completions.create({
|
|
model: "gpt-4o",
|
|
messages: chatMessages,
|
|
stream: true,
|
|
max_tokens: 4096,
|
|
});
|
|
|
|
let fullResponse = "";
|
|
|
|
for await (const chunk of stream) {
|
|
const content = chunk.choices[0]?.delta?.content || "";
|
|
if (content) {
|
|
fullResponse += content;
|
|
res.write(`data: ${JSON.stringify({ content })}\n\n`);
|
|
}
|
|
}
|
|
|
|
let interactionId: number | undefined;
|
|
try {
|
|
interactionId = await learningService.saveInteraction({
|
|
userId,
|
|
tenantId: tenantId || undefined,
|
|
source: 'agent_chat',
|
|
sessionId: conversationId.toString(),
|
|
question: content,
|
|
answer: fullResponse,
|
|
context: diagnosticContext ? { diagnostics: true } : undefined,
|
|
dataSourcesAccessed: knowledgeResults.length > 0 ? ['knowledge_base'] : undefined,
|
|
});
|
|
} catch (err) {
|
|
console.error("[Learning] Error saving chat interaction:", err);
|
|
}
|
|
|
|
const assistantMessage = await chatStorage.createMessage(conversationId, "assistant", fullResponse, interactionId);
|
|
|
|
res.write(`data: ${JSON.stringify({ done: true, messageId: assistantMessage.id, interactionId })}\n\n`);
|
|
res.end();
|
|
} catch (error) {
|
|
console.error("Error sending message:", error);
|
|
if (res.headersSent) {
|
|
res.write(`data: ${JSON.stringify({ error: "Failed to send message" })}\n\n`);
|
|
res.end();
|
|
} else {
|
|
res.status(500).json({ error: "Failed to send message" });
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get("/api/knowledge-base", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
if (req.user?.role !== "admin") {
|
|
return res.status(403).json({ error: "Admin access required" });
|
|
}
|
|
|
|
const entries = await chatStorage.getAllKnowledgeBase();
|
|
res.json(entries);
|
|
} catch (error) {
|
|
console.error("Error fetching knowledge base:", error);
|
|
res.status(500).json({ error: "Failed to fetch knowledge base" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/knowledge-base", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
if (req.user?.role !== "admin") {
|
|
return res.status(403).json({ error: "Admin access required" });
|
|
}
|
|
|
|
const { title, content, author, category, source } = req.body;
|
|
|
|
if (!title || !content || !author || !category) {
|
|
return res.status(400).json({ error: "Missing required fields: title, content, author, category" });
|
|
}
|
|
|
|
const entry = await chatStorage.createKnowledgeBaseEntry({ title, content, author, category, source });
|
|
res.status(201).json(entry);
|
|
} catch (error) {
|
|
console.error("Error creating knowledge base entry:", error);
|
|
res.status(500).json({ error: "Failed to create knowledge base entry" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/knowledge-base/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
if (req.user?.role !== "admin") {
|
|
return res.status(403).json({ error: "Admin access required" });
|
|
}
|
|
|
|
const id = parseInt(req.params.id);
|
|
await chatStorage.deleteKnowledgeBaseEntry(id);
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
console.error("Error deleting knowledge base entry:", error);
|
|
res.status(500).json({ error: "Failed to delete knowledge base entry" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/agent/chat", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
const { message, conversationHistory = [] } = req.body;
|
|
|
|
if (!message || typeof message !== "string") {
|
|
return res.status(400).json({ error: "Message is required" });
|
|
}
|
|
|
|
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [
|
|
{
|
|
role: "system",
|
|
content: `Você é o Arcádia Agent, um assistente inteligente de negócios especializado em gestão de projetos, metodologias ágeis (Scrum/Kanban), consultoria empresarial e análise de dados. Você ajuda profissionais a:
|
|
- Gerenciar tarefas e sprints de projetos
|
|
- Aplicar metodologias ágeis corretamente
|
|
- Analisar problemas de gestão e propor soluções
|
|
- Criar estimativas de esforço e custo
|
|
- Melhorar processos e produtividade
|
|
|
|
Responda de forma clara, objetiva e prática. Use exemplos quando apropriado.`
|
|
}
|
|
];
|
|
|
|
for (const msg of conversationHistory) {
|
|
if (msg.role === "user" || msg.role === "assistant") {
|
|
messages.push({ role: msg.role, content: msg.content });
|
|
}
|
|
}
|
|
|
|
messages.push({ role: "user", content: message });
|
|
|
|
const completion = await openai.chat.completions.create({
|
|
model: "gpt-4o",
|
|
messages,
|
|
max_tokens: 2048,
|
|
temperature: 0.7,
|
|
});
|
|
|
|
const responseContent = completion.choices[0]?.message?.content || "Desculpe, não consegui gerar uma resposta.";
|
|
|
|
res.json({ response: responseContent });
|
|
} catch (error) {
|
|
console.error("Error in agent chat:", error);
|
|
res.status(500).json({ error: "Failed to process chat request" });
|
|
}
|
|
});
|
|
|
|
// Save Manus result to conversation history (idempotent via runId)
|
|
app.post("/api/conversations/:id/manus-result", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
const conversationId = parseInt(req.params.id);
|
|
const userId = req.user!.id;
|
|
const { userMessage, assistantMessage, runId, includeUserMessage } = req.body;
|
|
|
|
const conversation = await chatStorage.getConversation(conversationId);
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
if (conversation.userId !== userId) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
// Check if this runId was already saved (idempotency) using metadata pattern
|
|
const existingMessages = await chatStorage.getMessagesByConversation(conversationId);
|
|
const alreadySaved = existingMessages.some(m =>
|
|
m.role === "assistant" && m.content?.startsWith(assistantMessage?.substring(0, 100))
|
|
);
|
|
|
|
if (alreadySaved && runId) {
|
|
return res.json({ success: true, alreadySaved: true });
|
|
}
|
|
|
|
// Only save user message if explicitly requested (for new conversations)
|
|
if (includeUserMessage && userMessage) {
|
|
await chatStorage.createMessage(conversationId, "user", userMessage);
|
|
}
|
|
|
|
// Save assistant message (clean, no markers)
|
|
const savedMessage = await chatStorage.createMessage(
|
|
conversationId,
|
|
"assistant",
|
|
assistantMessage
|
|
);
|
|
|
|
res.json({ success: true, messageId: savedMessage.id });
|
|
} catch (error) {
|
|
console.error("Error saving Manus result:", error);
|
|
res.status(500).json({ error: "Failed to save result" });
|
|
}
|
|
});
|
|
|
|
// Unified Agent endpoint - auto-detects if message needs tools (Manus) or simple chat
|
|
app.post("/api/agent/unified", async (req: Request, res: Response) => {
|
|
try {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
const { content, conversationId, attachedFiles } = req.body;
|
|
const userId = req.user!.id;
|
|
|
|
if (!content || typeof content !== "string") {
|
|
return res.status(400).json({ error: "Content is required" });
|
|
}
|
|
|
|
// Keywords that indicate the message needs Manus tools
|
|
const toolKeywords = [
|
|
// Actions
|
|
'gerar relatório', 'criar relatório', 'exportar', 'enviar para',
|
|
'pesquisar', 'buscar na web', 'calcular', 'analisar dados',
|
|
'comparar com', 'pesquisa de mercado', 'agendar', 'criar gráfico',
|
|
// ERP/BI
|
|
'consultar erp', 'consulta erp', 'dados do erp', 'vendas', 'estoque',
|
|
'notas fiscais', 'clientes', 'fornecedores', 'financeiro',
|
|
'dataset', 'bi', 'dashboard',
|
|
// Knowledge
|
|
'base de conhecimento', 'documentos', 'aprender',
|
|
// Excel
|
|
'excel', 'planilha', 'xlsx',
|
|
// Complex analysis with files
|
|
'analisar arquivo', 'processar arquivo', 'extrair dados'
|
|
];
|
|
|
|
// Check if message contains tool keywords or has attached files
|
|
const contentLower = content.toLowerCase();
|
|
const needsTools = toolKeywords.some(kw => contentLower.includes(kw)) ||
|
|
(attachedFiles && attachedFiles.length > 0 &&
|
|
(contentLower.includes('analis') || contentLower.includes('process') ||
|
|
contentLower.includes('relat') || contentLower.includes('export')));
|
|
|
|
res.json({
|
|
mode: needsTools ? 'manus' : 'chat',
|
|
needsTools
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in unified agent:", error);
|
|
res.status(500).json({ error: "Failed to process request" });
|
|
}
|
|
});
|
|
}
|