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 type { Db } from "@paperclipai/db";
|
||||||
import { chatConversations, chatMessages } from "@paperclipai/db";
|
import { agents, chatConversations, chatMessageBookmarks, chatMessages } from "@paperclipai/db";
|
||||||
import { notFound } from "../errors.js";
|
import { notFound } from "../errors.js";
|
||||||
|
|
||||||
export function chatService(db: Db) {
|
export function chatService(db: Db) {
|
||||||
|
|
@ -225,5 +225,251 @@ export function chatService(db: Db) {
|
||||||
yield word + " ";
|
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