diff --git a/server/src/services/chat.ts b/server/src/services/chat.ts index 803a94fd..0ccb0004 100644 --- a/server/src/services/chat.ts +++ b/server/src/services/chat.ts @@ -1,6 +1,6 @@ -import { and, desc, eq, gt, ilike, isNull, lt } from "drizzle-orm"; +import { and, asc, desc, eq, gt, ilike, isNull, lt, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { chatConversations, chatMessages } from "@paperclipai/db"; +import { agents, chatConversations, chatMessageBookmarks, chatMessages } from "@paperclipai/db"; import { notFound } from "../errors.js"; export function chatService(db: Db) { @@ -225,5 +225,251 @@ export function chatService(db: Db) { yield word + " "; } }, + + async searchMessages( + companyId: string, + query: string, + opts: { limit?: number } = {}, + ) { + if (!query.trim()) return { items: [] }; + + const limit = Math.min(opts.limit ?? 20, 50); + const tsQuery = sql`plainto_tsquery('english', ${query})`; + const tsRank = sql`ts_rank("chat_messages"."content_search", plainto_tsquery('english', ${query}))`; + + const rows = await db + .select({ + messageId: chatMessages.id, + conversationId: chatMessages.conversationId, + conversationTitle: chatConversations.title, + content: chatMessages.content, + role: chatMessages.role, + agentId: chatMessages.agentId, + createdAt: chatMessages.createdAt, + rank: tsRank, + }) + .from(chatMessages) + .innerJoin( + chatConversations, + and( + eq(chatMessages.conversationId, chatConversations.id), + eq(chatConversations.companyId, companyId), + isNull(chatConversations.deletedAt), + ), + ) + .where(sql`"chat_messages"."content_search" @@ ${tsQuery}`) + .orderBy(desc(tsRank)) + .limit(limit); + + return { + items: rows.map((r) => ({ + messageId: r.messageId, + conversationId: r.conversationId, + conversationTitle: r.conversationTitle, + content: r.content, + role: r.role as "user" | "assistant" | "system", + agentId: r.agentId, + createdAt: r.createdAt.toISOString(), + rank: Number(r.rank), + })), + }; + }, + + async toggleBookmark(companyId: string, messageId: string, conversationId: string) { + return db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: chatMessageBookmarks.id }) + .from(chatMessageBookmarks) + .where( + and( + eq(chatMessageBookmarks.companyId, companyId), + eq(chatMessageBookmarks.messageId, messageId), + ), + ); + + if (existing) { + await tx + .delete(chatMessageBookmarks) + .where(eq(chatMessageBookmarks.id, existing.id)); + return { bookmarked: false }; + } else { + await tx + .insert(chatMessageBookmarks) + .values({ companyId, messageId, conversationId }); + return { bookmarked: true }; + } + }); + }, + + async getBookmarks(companyId: string, opts: { conversationId?: string } = {}) { + const conditions = [eq(chatMessageBookmarks.companyId, companyId)]; + if (opts.conversationId) { + conditions.push(eq(chatMessageBookmarks.conversationId, opts.conversationId)); + } + + const rows = await db + .select({ + id: chatMessageBookmarks.id, + companyId: chatMessageBookmarks.companyId, + messageId: chatMessageBookmarks.messageId, + conversationId: chatMessageBookmarks.conversationId, + createdAt: chatMessageBookmarks.createdAt, + messageRole: chatMessages.role, + messageContent: chatMessages.content, + messageAgentId: chatMessages.agentId, + messageMessageType: chatMessages.messageType, + messageCreatedAt: chatMessages.createdAt, + messageUpdatedAt: chatMessages.updatedAt, + conversationTitle: chatConversations.title, + }) + .from(chatMessageBookmarks) + .innerJoin(chatMessages, eq(chatMessageBookmarks.messageId, chatMessages.id)) + .innerJoin(chatConversations, eq(chatMessageBookmarks.conversationId, chatConversations.id)) + .where(and(...conditions)) + .orderBy(desc(chatMessageBookmarks.createdAt)); + + return { + items: rows.map((r) => ({ + id: r.id, + companyId: r.companyId, + messageId: r.messageId, + conversationId: r.conversationId, + createdAt: r.createdAt.toISOString(), + message: { + id: r.messageId, + conversationId: r.conversationId, + role: r.messageRole as "user" | "assistant" | "system", + content: r.messageContent, + agentId: r.messageAgentId, + messageType: r.messageMessageType, + createdAt: r.messageCreatedAt.toISOString(), + updatedAt: r.messageUpdatedAt?.toISOString() ?? null, + }, + conversationTitle: r.conversationTitle, + })), + }; + }, + + async branchConversation(parentConversationId: string, branchFromMessageId: string, companyId: string) { + // Get the branch point message + const [branchMsg] = await db + .select({ createdAt: chatMessages.createdAt }) + .from(chatMessages) + .where(eq(chatMessages.id, branchFromMessageId)); + + if (!branchMsg) throw notFound("Branch message not found"); + + // Get all messages up to and including the branch point, ordered asc + const messagesToCopy = await db + .select() + .from(chatMessages) + .where( + and( + eq(chatMessages.conversationId, parentConversationId), + lte(chatMessages.createdAt, branchMsg.createdAt), + ), + ) + .orderBy(asc(chatMessages.createdAt)); + + // Create new child conversation + const [newConversation] = await db + .insert(chatConversations) + .values({ + companyId, + parentConversationId, + branchFromMessageId, + title: null, + agentId: null, + }) + .returning(); + + // Copy messages into new conversation + if (messagesToCopy.length > 0) { + await db.insert(chatMessages).values( + messagesToCopy.map(({ id: _id, conversationId: _convId, ...rest }) => ({ + ...rest, + conversationId: newConversation!.id, + })), + ); + } + + return newConversation!; + }, + + async listBranches(conversationId: string) { + return db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.parentConversationId, conversationId), + isNull(chatConversations.deletedAt), + ), + ) + .orderBy(desc(chatConversations.createdAt)); + }, + + async exportConversation(conversationId: string, format: "markdown" | "json") { + // Note: loads all messages in memory. For very large conversations, consider streaming in future. + const conversation = await this.getConversation(conversationId); + + const rows = await db + .select({ + id: chatMessages.id, + conversationId: chatMessages.conversationId, + role: chatMessages.role, + content: chatMessages.content, + agentId: chatMessages.agentId, + messageType: chatMessages.messageType, + createdAt: chatMessages.createdAt, + updatedAt: chatMessages.updatedAt, + agentName: agents.name, + }) + .from(chatMessages) + .leftJoin(agents, eq(chatMessages.agentId, agents.id)) + .where(eq(chatMessages.conversationId, conversationId)) + .orderBy(asc(chatMessages.createdAt)); + + const title = conversation.title ?? "Untitled"; + const dateStr = new Date(conversation.createdAt).toISOString().slice(0, 10); + + // Sanitize title for filename: lowercase, hyphens, no special chars, max 50 + const slug = title + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .slice(0, 50); + + if (format === "json") { + const messages = rows.map((r) => ({ + id: r.id, + conversationId: r.conversationId, + role: r.role, + content: r.content, + agentId: r.agentId, + agentName: r.agentName ?? null, + messageType: r.messageType, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt?.toISOString() ?? null, + })); + const content = JSON.stringify( + { conversation: { id: conversation.id, title, createdAt: conversation.createdAt }, messages }, + null, + 2, + ); + return { content, filename: `${slug}-${dateStr}.json` }; + } + + // Markdown format + const exportDate = new Date().toISOString().slice(0, 10); + let content = `# ${title}\nExported: ${exportDate}\n\n---\n\n`; + for (const r of rows) { + const speaker = r.role === "user" ? "You" : (r.agentName ?? r.role); + const timestamp = r.createdAt.toISOString(); + content += `**${speaker}** (${timestamp})\n${r.content}\n\n---\n\n`; + } + + return { content, filename: `${slug}-${dateStr}.md` }; + }, }; }