import "server-only"; import { and, asc, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import { getDb, conversations, messages, folders, sharedConversations, messageAttachments, type Conversation } from "@/db"; import { ids } from "@/lib/ids"; import { ApiError } from "@/lib/api"; import { toPublicMessage, type StoredPart } from "@/lib/chat/service"; import type { ProviderId } from "@/lib/ai/core/types"; export interface PublicConversation { id: string; title: string; folderId: string | null; /** Workspace the conversation belongs to. Optional in the type so optimistic client objects stay assignable. */ projectId?: string | null; pinned: boolean; archived: boolean; modelKey: string | null; provider: ProviderId | null; systemPrompt: string | null; settings: Record; messageCount: number; totalCostUsd: number; totalInputTokens: number; totalOutputTokens: number; parentConversationId: string | null; lastMessageAt: string | null; createdAt: string; updatedAt: string; } export function toPublicConversation(c: Conversation): PublicConversation { return { id: c.id, title: c.title, folderId: c.folderId, projectId: c.projectId ?? null, pinned: c.pinned, archived: c.archived, modelKey: c.modelKey, provider: c.provider, systemPrompt: c.systemPrompt, settings: c.settings, messageCount: c.messageCount, totalCostUsd: c.totalCostUsd, totalInputTokens: c.totalInputTokens, totalOutputTokens: c.totalOutputTokens, parentConversationId: c.parentConversationId, lastMessageAt: c.lastMessageAt?.toISOString() ?? null, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), }; } export interface ListFilter { q?: string; provider?: ProviderId; modelKey?: string; folderId?: string | "none"; /** Workspace filter: a project id, or `"none"` for conversations outside any project. */ projectId?: string | "none"; archived?: boolean; pinned?: boolean; since?: Date; until?: Date; limit?: number; cursor?: string; // updatedAt ISO } export async function listConversations(userId: string, f: ListFilter = {}) { const conds: SQL[] = [eq(conversations.userId, userId), eq(conversations.archived, f.archived ?? false)]; if (f.q) conds.push(ilike(conversations.title, `%${f.q.replace(/[%_]/g, "\\$&")}%`)); if (f.provider) conds.push(eq(conversations.provider, f.provider)); if (f.modelKey) conds.push(eq(conversations.modelKey, f.modelKey)); if (f.folderId === "none") conds.push(isNull(conversations.folderId)); else if (f.folderId) conds.push(eq(conversations.folderId, f.folderId)); if (f.projectId === "none") conds.push(isNull(conversations.projectId)); else if (f.projectId) conds.push(eq(conversations.projectId, f.projectId)); if (f.pinned !== undefined) conds.push(eq(conversations.pinned, f.pinned)); if (f.since) conds.push(sql`${conversations.updatedAt} >= ${f.since}`); if (f.until) conds.push(sql`${conversations.updatedAt} <= ${f.until}`); if (f.cursor) conds.push(sql`${conversations.updatedAt} < ${new Date(f.cursor)}`); const limit = Math.min(f.limit ?? 60, 200); const rows = await getDb() .select() .from(conversations) .where(and(...conds)) .orderBy(desc(conversations.pinned), desc(conversations.updatedAt)) .limit(limit + 1); const hasMore = rows.length > limit; const page = rows.slice(0, limit).map(toPublicConversation); return { conversations: page, nextCursor: hasMore ? page[page.length - 1].updatedAt : null }; } export async function getConversationWithMessages(userId: string, id: string) { const db = getDb(); const [c] = await db .select() .from(conversations) .where(and(eq(conversations.id, id), eq(conversations.userId, userId))) .limit(1); if (!c) throw new ApiError(404, "Conversation not found", "NOT_FOUND"); const rows = await db.select().from(messages).where(eq(messages.conversationId, id)).orderBy(asc(messages.createdAt)); return { conversation: toPublicConversation(c), messages: rows.map(toPublicMessage) }; } export async function createConversation(userId: string, input: { title?: string; modelKey?: string | null; systemPrompt?: string | null; folderId?: string | null; projectId?: string | null; settings?: Record }) { const [c] = await getDb() .insert(conversations) .values({ id: ids.conversation(), userId, title: input.title?.trim() || "New chat", titleSource: input.title ? "user" : "auto", modelKey: input.modelKey ?? null, provider: (input.modelKey?.split("/")[0] as ProviderId | undefined) ?? null, systemPrompt: input.systemPrompt ?? null, folderId: input.folderId ?? null, projectId: input.projectId ?? null, settings: input.settings ?? {} }) .returning(); return toPublicConversation(c); } export async function updateConversation(userId: string, id: string, patch: { title?: string; pinned?: boolean; archived?: boolean; folderId?: string | null; projectId?: string | null; systemPrompt?: string | null; settings?: Record; modelKey?: string | null }) { const set: Partial = { updatedAt: new Date() }; if (patch.title !== undefined) { set.title = patch.title.trim().slice(0, 200) || "New chat"; set.titleSource = "user"; } if (patch.pinned !== undefined) set.pinned = patch.pinned; if (patch.archived !== undefined) set.archived = patch.archived; if (patch.folderId !== undefined) set.folderId = patch.folderId; if (patch.projectId !== undefined) set.projectId = patch.projectId; if (patch.systemPrompt !== undefined) set.systemPrompt = patch.systemPrompt; if (patch.settings !== undefined) set.settings = patch.settings; if (patch.modelKey !== undefined) { set.modelKey = patch.modelKey; set.provider = (patch.modelKey?.split("/")[0] as ProviderId | undefined) ?? null; } const [c] = await getDb() .update(conversations) .set(set) .where(and(eq(conversations.id, id), eq(conversations.userId, userId))) .returning(); if (!c) throw new ApiError(404, "Conversation not found", "NOT_FOUND"); return toPublicConversation(c); } export async function deleteConversation(userId: string, id: string) { const res = await getDb() .delete(conversations) .where(and(eq(conversations.id, id), eq(conversations.userId, userId))) .returning({ id: conversations.id }); if (!res.length) throw new ApiError(404, "Conversation not found", "NOT_FOUND"); } export async function deleteMessage(userId: string, conversationId: string, messageId: string) { const db = getDb(); const res = await db .delete(messages) .where(and(eq(messages.id, messageId), eq(messages.conversationId, conversationId), eq(messages.userId, userId))) .returning({ id: messages.id }); if (!res.length) throw new ApiError(404, "Message not found", "NOT_FOUND"); await db .update(conversations) .set({ messageCount: sql`(select count(*)::int from ${messages} where ${messages.conversationId} = ${conversationId} and ${messages.active} = true)`, updatedAt: new Date() }) .where(eq(conversations.id, conversationId)); } /** Copy a conversation (active messages only). When `uptoMessageId` is given → branch. */ export async function duplicateConversation(userId: string, id: string, opts: { uptoMessageId?: string; title?: string } = {}) { const db = getDb(); const [src] = await db .select() .from(conversations) .where(and(eq(conversations.id, id), eq(conversations.userId, userId))) .limit(1); if (!src) throw new ApiError(404, "Conversation not found", "NOT_FOUND"); let rows = await db .select() .from(messages) .where(and(eq(messages.conversationId, id), eq(messages.active, true))) .orderBy(asc(messages.createdAt)); if (opts.uptoMessageId) { const idx = rows.findIndex((r) => r.id === opts.uptoMessageId); if (idx < 0) throw new ApiError(404, "Message not found", "NOT_FOUND"); rows = rows.slice(0, idx + 1); } const isBranch = Boolean(opts.uptoMessageId); const [created] = await db .insert(conversations) .values({ id: ids.conversation(), userId, title: opts.title ?? (isBranch ? `${src.title} (branch)` : `${src.title} (copy)`), titleSource: "user", folderId: src.folderId, modelKey: src.modelKey, provider: src.provider, systemPrompt: src.systemPrompt, settings: src.settings, parentConversationId: isBranch ? src.id : null, branchedFromMessageId: opts.uptoMessageId ?? null, messageCount: rows.length, totalCostUsd: rows.reduce((s, r) => s + (r.costUsd ?? 0), 0), totalInputTokens: rows.reduce((s, r) => s + ((r.usage as { inputTokens?: number } | null)?.inputTokens ?? 0), 0), totalOutputTokens: rows.reduce((s, r) => s + ((r.usage as { outputTokens?: number } | null)?.outputTokens ?? 0), 0), lastMessageAt: rows.length ? rows[rows.length - 1].createdAt : null, }) .returning(); if (rows.length) { const idMap = new Map(); const values = rows.map((r) => { const nid = ids.message(); idMap.set(r.id, nid); return { ...r, id: nid, conversationId: created.id, parentMessageId: null, version: 1, active: true, createdAt: r.createdAt, updatedAt: new Date() }; }); await db.insert(messages).values(values); // duplicate attachment rows so deleting the original does not orphan the copy const attIds = rows.flatMap((r) => (r.parts as StoredPart[]).filter((p) => p.type === "attachment").map((p) => (p as { attachmentId: string }).attachmentId)); if (attIds.length) { const atts = await db.select().from(messageAttachments).where(inArray(messageAttachments.id, attIds)); for (const a of atts) { const newAttId = ids.attachment(); const newMsgId = a.messageId ? idMap.get(a.messageId) ?? null : null; await db.insert(messageAttachments).values({ ...a, id: newAttId, messageId: newMsgId, conversationId: created.id, createdAt: new Date() }); // rewrite references in parts for (const v of values) { v.parts = (v.parts as StoredPart[]).map((p) => (p.type === "attachment" && p.attachmentId === a.id ? { ...p, attachmentId: newAttId } : p)); } } for (const v of values) await db.update(messages).set({ parts: v.parts }).where(eq(messages.id, v.id)); } } return toPublicConversation(created); } // --- folders ------------------------------------------------------------------ export async function listFolders(userId: string) { return getDb().select().from(folders).where(eq(folders.userId, userId)).orderBy(asc(folders.sortOrder), asc(folders.name)); } export async function createFolder(userId: string, name: string, color?: string | null) { const [f] = await getDb().insert(folders).values({ id: ids.folder(), userId, name: name.trim().slice(0, 80), color: color ?? null }).returning(); return f; } export async function updateFolder(userId: string, id: string, patch: { name?: string; color?: string | null; sortOrder?: number }) { const [f] = await getDb() .update(folders) .set({ ...(patch.name !== undefined ? { name: patch.name.trim().slice(0, 80) } : {}), ...(patch.color !== undefined ? { color: patch.color } : {}), ...(patch.sortOrder !== undefined ? { sortOrder: patch.sortOrder } : {}) }) .where(and(eq(folders.id, id), eq(folders.userId, userId))) .returning(); if (!f) throw new ApiError(404, "Folder not found", "NOT_FOUND"); return f; } export async function deleteFolder(userId: string, id: string) { await getDb() .delete(folders) .where(and(eq(folders.id, id), eq(folders.userId, userId))); } // --- search --------------------------------------------------------------------- export async function searchEverything(userId: string, q: string, limit = 20) { const db = getDb(); const term = `%${q.replace(/[%_]/g, "\\$&")}%`; const convs = await db .select({ id: conversations.id, title: conversations.title, updatedAt: conversations.updatedAt, modelKey: conversations.modelKey }) .from(conversations) .where(and(eq(conversations.userId, userId), ilike(conversations.title, term))) .orderBy(desc(conversations.updatedAt)) .limit(limit); const msgs = await db .select({ id: messages.id, conversationId: messages.conversationId, role: messages.role, content: messages.content, createdAt: messages.createdAt, title: conversations.title }) .from(messages) .innerJoin(conversations, eq(messages.conversationId, conversations.id)) .where(and(eq(messages.userId, userId), eq(messages.active, true), or(ilike(messages.content, term)))) .orderBy(desc(messages.createdAt)) .limit(limit); return { conversations: convs.map((c) => ({ ...c, updatedAt: c.updatedAt.toISOString() })), messages: msgs.map((m) => ({ ...m, createdAt: m.createdAt.toISOString(), snippet: snippet(m.content, q) })), }; } function snippet(text: string, q: string, span = 90): string { const idx = text.toLowerCase().indexOf(q.toLowerCase()); if (idx < 0) return text.slice(0, span); const start = Math.max(0, idx - span / 2); return (start > 0 ? "…" : "") + text.slice(start, start + span) + (start + span < text.length ? "…" : ""); } // --- export ----------------------------------------------------------------------- export const EXPORT_FORMATS = ["json", "markdown", "txt", "html"] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; export interface ExportResult { filename: string; contentType: string; body: string; } /** * Export a conversation (active branch only). * - `json` machine-readable, includes conversation metadata and message parts * - `markdown` human-readable transcript * - `txt` plain-text transcript (no markup) * - `html` self-contained, print-optimized document (used for "PDF (print)"; `opts.print` auto-opens the dialog) */ export async function exportConversation(userId: string, id: string, format: ExportFormat, opts: { print?: boolean; appUrl?: string } = {}): Promise { const { conversation, messages: msgs } = await getConversationWithMessages(userId, id); const active = msgs.filter((m) => (m as unknown as { active?: boolean }).active !== false); const exportedAt = new Date().toISOString(); const base = slug(conversation.title); if (format === "json") return { filename: `${base}.json`, contentType: "application/json", body: JSON.stringify({ exportedAt, app: "PolyLLM", conversation, messages: active }, null, 2) }; if (format === "markdown") { const lines = [`# ${conversation.title}`, "", `_Exported from PolyLLM on ${exportedAt.slice(0, 10)}_`, conversation.systemPrompt ? `\n> **System prompt:** ${conversation.systemPrompt}` : "", ""]; for (const m of active) { const who = m.role === "user" ? "You" : m.role === "assistant" ? `Assistant (${m.modelKey ?? "model"})` : m.role; lines.push(`## ${who}`, ""); for (const p of m.parts) { if (p.type === "reasoning" && p.text) lines.push("
Reasoning", "", p.text, "", "
", ""); else if (p.type === "attachment") lines.push(`_Attachment: ${p.name} (${p.mimeType}, ${p.sizeBytes} bytes)_`, ""); else if (p.type === "tool-call") lines.push(`**Tool call** \`${p.name}\`(${JSON.stringify(p.arguments)}) → ${JSON.stringify(p.result ?? null)}`, ""); } lines.push(m.content, ""); } return { filename: `${base}.md`, contentType: "text/markdown; charset=utf-8", body: lines.join("\n") }; } if (format === "txt") { const rule = "─".repeat(60); const lines = [conversation.title.toUpperCase(), `Exported from PolyLLM · ${exportedAt.replace("T", " ").slice(0, 16)} UTC`, `${active.length} message${active.length === 1 ? "" : "s"}`, rule, ""]; if (conversation.systemPrompt) lines.push("SYSTEM PROMPT", conversation.systemPrompt, "", rule, ""); for (const m of active) { const who = m.role === "user" ? "You" : m.role === "assistant" ? `Assistant · ${m.modelKey ?? "model"}` : m.role; const when = new Date(m.createdAt).toISOString().replace("T", " ").slice(0, 16); lines.push(`[${when}] ${who}`, ""); const attachments = m.parts.filter((p): p is Extract => p.type === "attachment"); for (const a of attachments) lines.push(`(attachment: ${a.name}, ${a.mimeType}, ${a.sizeBytes} bytes)`); const tools = m.parts.filter((p): p is Extract => p.type === "tool-call"); for (const t of tools) lines.push(`(tool call: ${t.name} ${t.argumentsText ?? JSON.stringify(t.arguments)})`); lines.push(m.content.trim() || "(no text)", "", rule, ""); } return { filename: `${base}.txt`, contentType: "text/plain; charset=utf-8", body: lines.join("\n") }; } // html const { buildExportHtml } = await import("@/lib/export/html-document"); const doc = buildExportHtml({ title: conversation.title, createdAt: conversation.createdAt, exportedAt, systemPrompt: conversation.systemPrompt, appUrl: opts.appUrl ?? "https://www.polyllm.io", autoPrint: Boolean(opts.print), totals: { costUsd: conversation.totalCostUsd, inputTokens: conversation.totalInputTokens, outputTokens: conversation.totalOutputTokens }, messages: active.map((m) => ({ role: m.role, content: m.content, modelKey: m.modelKey, createdAt: m.createdAt, reasoning: m.parts .filter((p): p is Extract => p.type === "reasoning") .map((p) => p.text) .join("\n\n"), tools: m.parts.filter((p): p is Extract => p.type === "tool-call").map((t) => ({ name: t.name, argumentsText: t.argumentsText ?? JSON.stringify(t.arguments), isError: t.isError })), attachments: m.parts.filter((p): p is Extract => p.type === "attachment").map((a) => ({ name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes })), citations: m.parts.filter((p): p is Extract => p.type === "citation").map((c) => ({ url: c.url, title: c.title })), usage: m.usage as { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null, costUsd: m.costUsd, latencyMs: m.latencyMs, })), }); return { filename: `${base}.html`, contentType: "text/html; charset=utf-8", body: doc }; } function slug(s: string): string { return ( s .normalize("NFD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60) || "conversation" ); } // --- sharing ------------------------------------------------------------------------ /** * First element of a share snapshot: metadata about the selection. Message renderers skip it because it has * no `role` (see `parseSnapshot` in app/share/[id]/page.tsx). */ export interface ShareSnapshotMeta { $meta: true; version: 2; partial: boolean; selectedCount: number; totalCount: number; generatedAt: string; } export function readShareMeta(snapshot: unknown): ShareSnapshotMeta | null { if (!Array.isArray(snapshot) || !snapshot.length) return null; const first = snapshot[0] as Partial | null; return first && typeof first === "object" && first.$meta === true ? (first as ShareSnapshotMeta) : null; } /** * Create (or refresh) a public share link. * - Without `messageIds`: one "entire conversation" link per conversation — an existing active full link is * refreshed in place (same URL keeps working). * - With `messageIds`: snapshots only the selected active messages, in conversation order, as a NEW link. * Attachments are never included (replaced by a `[attachment name]` text part). */ export async function shareConversation(userId: string, id: string, opts: { messageIds?: string[] } = {}) { const db = getDb(); const { conversation, messages: msgs } = await getConversationWithMessages(userId, id); const active = msgs.filter((m) => m.status !== "streaming" && (m as unknown as { active?: boolean }).active !== false); const wanted = opts.messageIds?.length ? new Set(opts.messageIds) : null; const selected = wanted ? active.filter((m) => wanted.has(m.id)) : active; if (wanted && !selected.length) throw new ApiError(400, "None of the selected messages exist in this conversation", "EMPTY_SELECTION"); const meta: ShareSnapshotMeta = { $meta: true, version: 2, partial: Boolean(wanted) && selected.length < active.length, selectedCount: selected.length, totalCount: active.length, generatedAt: new Date().toISOString() }; const snapshot: unknown[] = [ meta, ...selected.map((m) => ({ role: m.role, content: m.content, modelKey: m.modelKey, createdAt: m.createdAt, parts: m.parts.map((p) => (p.type === "attachment" ? ({ type: "text", text: `[attachment ${p.name}]` } as const) : p)).filter((p) => p.type === "text" || p.type === "reasoning" || p.type === "tool-call" || p.type === "citation"), usage: m.usage, latencyMs: m.latencyMs, })), ]; if (!meta.partial) { const existing = await db .select() .from(sharedConversations) .where(and(eq(sharedConversations.conversationId, id), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt))) .orderBy(asc(sharedConversations.createdAt)); const full = existing.find((s) => !(readShareMeta(s.snapshot)?.partial ?? false)); if (full) { await db.update(sharedConversations).set({ snapshot, title: conversation.title }).where(eq(sharedConversations.id, full.id)); return { id: full.id, partial: false, messageCount: selected.length, created: false }; } } const shareId = ids.share(); await db.insert(sharedConversations).values({ id: shareId, conversationId: id, userId, title: conversation.title, snapshot }); return { id: shareId, partial: meta.partial, messageCount: selected.length, created: true }; } /** Revoke every active link of a conversation. */ export async function revokeShare(userId: string, conversationId: string) { await getDb() .update(sharedConversations) .set({ revokedAt: new Date() }) .where(and(eq(sharedConversations.conversationId, conversationId), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt))); } /** Revoke one link by its public id. */ export async function revokeShareById(userId: string, shareId: string) { const res = await getDb() .update(sharedConversations) .set({ revokedAt: new Date() }) .where(and(eq(sharedConversations.id, shareId), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt))) .returning({ id: sharedConversations.id }); if (!res.length) throw new ApiError(404, "Share link not found", "NOT_FOUND"); } export interface ShareLinkItem { id: string; conversationId: string; title: string; createdAt: string; viewCount: number; /** Number of messages in the snapshot. */ messageCount: number; /** True when only selected messages were shared. */ partial: boolean; /** Relative path — prefix with the origin on the client. */ path: string; } function toShareItem(row: typeof sharedConversations.$inferSelect): ShareLinkItem { const meta = readShareMeta(row.snapshot); const count = meta ? meta.selectedCount : Array.isArray(row.snapshot) ? row.snapshot.length : 0; return { id: row.id, conversationId: row.conversationId, title: row.title, createdAt: row.createdAt.toISOString(), viewCount: row.viewCount, messageCount: count, partial: meta?.partial ?? false, path: `/share/${row.id}` }; } /** All active links of a user (Settings → Data), newest first. Optionally scoped to a conversation. */ export async function listShares(userId: string, conversationId?: string): Promise { const conds = [eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt)]; if (conversationId) conds.push(eq(sharedConversations.conversationId, conversationId)); const rows = await getDb() .select() .from(sharedConversations) .where(and(...conds)) .orderBy(desc(sharedConversations.createdAt)) .limit(200); return rows.map(toShareItem); } export async function getShare(userId: string, conversationId: string) { const [row] = await getDb() .select({ id: sharedConversations.id, createdAt: sharedConversations.createdAt, viewCount: sharedConversations.viewCount }) .from(sharedConversations) .where(and(eq(sharedConversations.conversationId, conversationId), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt))) .limit(1); return row ?? null; } /** Public read. Increments `viewCount` unless `peek` (used by `generateMetadata` so a page view counts once). */ export async function getPublicShare(shareId: string, opts: { peek?: boolean } = {}) { const db = getDb(); const [row] = await db .select() .from(sharedConversations) .where(and(eq(sharedConversations.id, shareId), isNull(sharedConversations.revokedAt), eq(sharedConversations.isPublic, true))) .limit(1); if (!row) return null; if (!opts.peek) await db.update(sharedConversations).set({ viewCount: sql`${sharedConversations.viewCount} + 1` }).where(eq(sharedConversations.id, shareId)); return row; }