feat(24-01): add searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation service methods

- searchMessages: tsvector FTS with ts_rank ordering, joins conversations for companyId scoping
- toggleBookmark: transactional insert-or-delete bookmark
- getBookmarks: joins bookmarks+messages+conversations, supports conversationId filter
- branchConversation: copies messages up to branch point into new child conversation
- listBranches: queries child conversations by parentConversationId
- exportConversation: LEFT JOINs agents for name resolution, produces Markdown or JSON with file headers
This commit is contained in:
Nexus Dev 2026-04-01 22:31:18 +00:00
parent d56e19c7b4
commit 61161a3561

View file

@ -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` };
},
};
}