- server/src/routes/chat.ts: destructure voiceMode from req.body in stream endpoint - server/src/routes/chat.ts: inject dual-output system prompt when voiceMode=full_voice (VPIPE-06) - server/src/routes/chat.ts: persist voiceMode to messageType column (voice_full/voice_input) - server/src/routes/chat-files.ts: remove old inline /transcribe endpoint (lines 297-386) - server/src/app.ts: import and mount voiceRoutes() after nexusSettingsRoutes()
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
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;
|
|
}
|