import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { chatService } from "../services/chat.js"; import { sendPushToAll } from "../services/pushService.js"; import { issueService } from "../services/issues.js"; import { assistantMemoryService } from "../services/assistant-memory.js"; import { nexusSettingsService } from "../services/nexus-settings.js"; import { puterProxyService } from "../services/puter-proxy.js"; import { z } from "zod"; import { createConversationSchema, updateConversationSchema, createMessageSchema, handoffSchema, searchMessagesSchema, branchConversationSchema, } from "@paperclipai/shared"; export function chatRoutes(db: Db): Router { const router = Router(); const svc = chatService(db); const issueSvc = issueService(db); // GET /api/companies/:companyId/conversations router.get("/companies/:companyId/conversations", async (req, res) => { assertBoard(req); assertCompanyAccess(req, req.params.companyId!); const { cursor, limit, includeArchived, search, agentId } = req.query; const result = await svc.listConversations(req.params.companyId!, { cursor: cursor as string | undefined, limit: limit ? Number(limit) : undefined, includeArchived: includeArchived === "true", search: search as string | undefined, agentId: agentId as string | undefined, }); res.json(result); }); // POST /api/companies/:companyId/conversations router.post("/companies/:companyId/conversations", async (req, res) => { assertBoard(req); assertCompanyAccess(req, req.params.companyId!); const data = createConversationSchema.parse(req.body); const conversation = await svc.createConversation(req.params.companyId!, data); res.status(201).json(conversation); }); // GET /api/conversations/:id router.get("/conversations/:id", async (req, res) => { assertBoard(req); const conversation = await svc.getConversation(req.params.id!); res.json(conversation); }); // PATCH /api/conversations/:id router.patch("/conversations/:id", async (req, res) => { assertBoard(req); const data = updateConversationSchema.parse(req.body); const conversation = await svc.updateConversation(req.params.id!, data); res.json(conversation); }); // DELETE /api/conversations/:id router.delete("/conversations/:id", async (req, res) => { assertBoard(req); await svc.softDeleteConversation(req.params.id!); res.status(204).end(); }); // GET /api/conversations/:id/messages router.get("/conversations/:id/messages", async (req, res) => { assertBoard(req); const { cursor, limit } = req.query; const result = await svc.listMessages(req.params.id!, { cursor: cursor as string | undefined, limit: limit ? Number(limit) : undefined, }); res.json(result); }); // POST /api/conversations/:id/messages router.post("/conversations/:id/messages", async (req, res) => { assertBoard(req); const data = createMessageSchema.parse(req.body); const message = await svc.addMessage(req.params.id!, data); res.status(201).json(message); }); // POST /api/conversations/:id/stream -- SSE streaming endpoint (CHAT-01, PERF-02) router.post("/conversations/:id/stream", async (req, res) => { assertBoard(req); const { content, agentId, voiceMode } = req.body as { content: string; agentId?: string; voiceMode?: "text" | "voice_input" | "full_voice"; }; if (!content || typeof content !== "string") { res.status(400).json({ error: "content is required" }); return; } // Resolve conversation and settings BEFORE flushing headers (Pitfall 3) const conversation = await svc.getConversation(req.params.id!); const settings = await nexusSettingsService().get(); const isAssistant = settings.mode !== "project_builder"; // Load memory facts if in assistant mode const memory = isAssistant ? await assistantMemoryService().get(conversation.companyId) : { facts: [] as string[], updatedAt: null }; // Try resolving puter token — fall back to echo if not configured let puterTokenAvailable = false; try { await puterProxyService(db).resolveToken(conversation.companyId); puterTokenAvailable = true; } catch { // No puter token — will fall back to echo stub } // Build messages array for AI call const recentMsgs = await svc.listMessages(req.params.id!, { limit: 20 }); // listMessages returns newest-first; reverse for chronological order const chronological = [...recentMsgs.items].reverse(); const messagesWithMemory: Array<{ role: string; content: string }> = []; // Inject memory as system message if applicable if (isAssistant && memory.facts.length > 0) { const memoryText = `[Memory from previous sessions]\n${memory.facts.map((f) => "- " + f).join("\n")}\n\nUse these facts to personalize your responses. Do not mention that you have a memory system unless asked.`; const capped = memoryText.slice(0, 2000); messagesWithMemory.push({ role: "system", content: capped }); } // Add recent conversation history for (const msg of chronological) { if (msg.role === "user" || msg.role === "assistant") { messagesWithMemory.push({ role: msg.role, content: msg.content }); } } // Add the new user message messagesWithMemory.push({ role: "user", content }); // Inject dual-output formatting prompt when voice mode is full_voice (VPIPE-06) if (voiceMode === "full_voice") { messagesWithMemory.push({ role: "system", content: [ "Format your response with EXACTLY these two labeled sections:", "", "SPOKEN: [Natural speech prose only. No markdown. No bullet points. No code blocks. Max 2-3 sentences for spoken delivery.]", "", "DETAILED: [Your full response with all detail, code blocks, and markdown formatting.]", ].join("\n"), }); } // Set SSE headers and flush BEFORE any generation (PERF-02) 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(); res.write(":ok\n\n"); const abort = new AbortController(); req.on("close", () => abort.abort()); try { let fullContent = ""; // Choose stream source: real AI or echo fallback const tokenStream = puterTokenAvailable ? puterProxyService(db).chatStream(conversation.companyId, agentId || undefined, messagesWithMemory, undefined, abort.signal) : svc.streamEcho(content, abort.signal); for await (const token of tokenStream) { if (!res.writable) break; fullContent += token; res.write(`data: ${JSON.stringify({ type: "token", token })}\n\n`); } if (res.writable && !abort.signal.aborted) { const message = await svc.addMessage(req.params.id!, { role: "assistant", content: fullContent.trim(), agentId: agentId || undefined, messageType: voiceMode === "full_voice" ? "voice_full" : voiceMode === "voice_input" ? "voice_input" : undefined, }); res.write(`data: ${JSON.stringify({ type: "done", messageId: message.id, content: fullContent.trim() })}\n\n`); // Fire push notification for offline subscribers (PWA-06) sendPushToAll(db, conversation.companyId, { title: "New agent response", body: fullContent.trim().slice(0, 100), data: { url: `/chat/${conversation.id}` }, }).catch(() => {}); // non-blocking // Append a brief memory fact after each assistant turn (non-blocking) if (isAssistant) { const fact = `User asked about: ${content.slice(0, 100)}. Assistant topic: ${fullContent.slice(0, 100)}`; assistantMemoryService().append(conversation.companyId, fact).catch(() => {}); } } } catch (err) { if (res.writable && !abort.signal.aborted) { res.write(`data: ${JSON.stringify({ type: "error", error: "Stream error" })}\n\n`); } } finally { res.end(); } }); // PATCH /api/conversations/:id/messages/:msgId -- Edit message content router.patch("/conversations/:id/messages/:msgId", async (req, res) => { assertBoard(req); const { content } = req.body; if (!content || typeof content !== "string") { res.status(400).json({ error: "content is required" }); return; } const message = await svc.editMessage(req.params.msgId!, content); if (!message) { res.status(404).json({ error: "Message not found" }); return; } res.json(message); }); // DELETE /api/conversations/:id/messages/after/:msgId -- Truncate messages after a given message router.delete("/conversations/:id/messages/after/:msgId", async (req, res) => { assertBoard(req); await svc.truncateMessagesAfter(req.params.id!, req.params.msgId!); res.status(204).end(); }); // POST /api/conversations/:id/handoff -- Brainstormer handoff to PM: inserts handoff + task_created messages and creates an issue router.post("/conversations/:id/handoff", async (req, res) => { assertBoard(req); const data = handoffSchema.parse(req.body); // Resolve companyId from conversation const conversation = await svc.getConversation(req.params.id!); const companyId = conversation.companyId; // 1. Insert handoff system message const handoffMsg = await svc.addSystemMessage(req.params.id!, { content: `Brainstormer \u2192 PM: spec handed off`, messageType: "handoff", }); // 2. Create issue from spec const specDescription = [ `**What:** ${data.spec.what}`, `**Why:** ${data.spec.why}`, data.spec.constraints ? `**Constraints:** ${data.spec.constraints}` : "", data.spec.success ? `**Success:** ${data.spec.success}` : "", ].filter(Boolean).join("\n\n"); const issue = await issueSvc.create(companyId, { title: data.spec.what.slice(0, 100), description: specDescription, status: "backlog", priority: "medium", }); // 3. Insert task_created system message await svc.addSystemMessage(req.params.id!, { content: JSON.stringify({ taskId: issue.identifier, taskTitle: issue.title, taskUrl: `/issues/${issue.id}`, }), messageType: "task_created", }); res.json({ handoffMessageId: handoffMsg.id, issues: [issue] }); }); // POST /api/conversations/:id/status-update -- Agent completion notification in chat router.post("/conversations/:id/status-update", async (req, res) => { assertBoard(req); const { agentName, taskId, taskTitle, taskUrl } = req.body; if (!agentName || !taskId) { res.status(400).json({ error: "agentName and taskId are required" }); return; } const message = await svc.addSystemMessage(req.params.id!, { content: JSON.stringify({ agentName, taskId, taskTitle, taskUrl }), messageType: "status_update", }); res.status(201).json(message); }); // GET /api/companies/:companyId/messages/search router.get("/companies/:companyId/messages/search", async (req, res) => { assertBoard(req); assertCompanyAccess(req, req.params.companyId!); let parsed: { q: string; limit?: number }; try { parsed = searchMessagesSchema.parse({ q: req.query.q, limit: req.query.limit }); } catch { res.status(400).json({ error: "Query must be at least 2 characters" }); return; } const result = await svc.searchMessages(req.params.companyId!, parsed.q, { limit: parsed.limit }); res.json(result); }); // POST /api/conversations/:id/bookmarks -- Toggle bookmark for a message router.post("/conversations/:id/bookmarks", async (req, res) => { assertBoard(req); const messageId = z.string().uuid().safeParse(req.body.messageId); if (!messageId.success) { res.status(400).json({ error: "messageId must be a valid UUID" }); return; } const conversationId = req.params.id!; const conversation = await svc.getConversation(conversationId); const result = await svc.toggleBookmark(conversation.companyId, messageId.data, conversationId); res.json(result); }); // GET /api/companies/:companyId/bookmarks router.get("/companies/:companyId/bookmarks", async (req, res) => { assertBoard(req); assertCompanyAccess(req, req.params.companyId!); const conversationId = req.query.conversationId as string | undefined; const result = await svc.getBookmarks(req.params.companyId!, { conversationId }); res.json(result); }); // POST /api/conversations/:id/branch -- Branch conversation from a message router.post("/conversations/:id/branch", async (req, res) => { assertBoard(req); const parsed = branchConversationSchema.parse(req.body); const conv = await svc.getConversation(req.params.id!); const newConversation = await svc.branchConversation(req.params.id!, parsed.branchFromMessageId, conv.companyId); res.status(201).json(newConversation); }); // GET /api/conversations/:id/branches -- List child conversations router.get("/conversations/:id/branches", async (req, res) => { assertBoard(req); const branches = await svc.listBranches(req.params.id!); res.json({ items: branches }); }); // GET /api/conversations/:id/export -- Export conversation as Markdown or JSON file router.get("/conversations/:id/export", async (req, res) => { assertBoard(req); const format = req.query.format === "json" ? "json" : "markdown"; const { content, filename } = await svc.exportConversation(req.params.id!, format); const mime = format === "json" ? "application/json" : "text/markdown"; res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.setHeader("Content-Type", mime); res.send(content); }); return router; }