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:
parent
d56e19c7b4
commit
61161a3561
1 changed files with 248 additions and 2 deletions
|
|
@ -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` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue