TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import "server-only";2import { and, asc, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm";3import { getDb, conversations, messages, folders, sharedConversations, messageAttachments, type Conversation } from "@/db";4import { ids } from "@/lib/ids";5import { ApiError } from "@/lib/api";6import { toPublicMessage, type StoredPart } from "@/lib/chat/service";7import type { ProviderId } from "@/lib/ai/core/types";89export interface PublicConversation {10 id: string;11 title: string;12 folderId: string | null;13 /** Workspace the conversation belongs to. Optional in the type so optimistic client objects stay assignable. */14 projectId?: string | null;15 pinned: boolean;16 archived: boolean;17 modelKey: string | null;18 provider: ProviderId | null;19 systemPrompt: string | null;20 settings: Record<string, unknown>;21 messageCount: number;22 totalCostUsd: number;23 totalInputTokens: number;24 totalOutputTokens: number;25 parentConversationId: string | null;26 lastMessageAt: string | null;27 createdAt: string;28 updatedAt: string;29}3031export function toPublicConversation(c: Conversation): PublicConversation {32 return {33 id: c.id,34 title: c.title,35 folderId: c.folderId,36 projectId: c.projectId ?? null,37 pinned: c.pinned,38 archived: c.archived,39 modelKey: c.modelKey,40 provider: c.provider,41 systemPrompt: c.systemPrompt,42 settings: c.settings,43 messageCount: c.messageCount,44 totalCostUsd: c.totalCostUsd,45 totalInputTokens: c.totalInputTokens,46 totalOutputTokens: c.totalOutputTokens,47 parentConversationId: c.parentConversationId,48 lastMessageAt: c.lastMessageAt?.toISOString() ?? null,49 createdAt: c.createdAt.toISOString(),50 updatedAt: c.updatedAt.toISOString(),51 };52}5354export interface ListFilter {55 q?: string;56 provider?: ProviderId;57 modelKey?: string;58 folderId?: string | "none";59 /** Workspace filter: a project id, or `"none"` for conversations outside any project. */60 projectId?: string | "none";61 archived?: boolean;62 pinned?: boolean;63 since?: Date;64 until?: Date;65 limit?: number;66 cursor?: string; // updatedAt ISO67}6869export async function listConversations(userId: string, f: ListFilter = {}) {70 const conds: SQL[] = [eq(conversations.userId, userId), eq(conversations.archived, f.archived ?? false)];71 if (f.q) conds.push(ilike(conversations.title, `%${f.q.replace(/[%_]/g, "\\$&")}%`));72 if (f.provider) conds.push(eq(conversations.provider, f.provider));73 if (f.modelKey) conds.push(eq(conversations.modelKey, f.modelKey));74 if (f.folderId === "none") conds.push(isNull(conversations.folderId));75 else if (f.folderId) conds.push(eq(conversations.folderId, f.folderId));76 if (f.projectId === "none") conds.push(isNull(conversations.projectId));77 else if (f.projectId) conds.push(eq(conversations.projectId, f.projectId));78 if (f.pinned !== undefined) conds.push(eq(conversations.pinned, f.pinned));79 if (f.since) conds.push(sql`${conversations.updatedAt} >= ${f.since}`);80 if (f.until) conds.push(sql`${conversations.updatedAt} <= ${f.until}`);81 if (f.cursor) conds.push(sql`${conversations.updatedAt} < ${new Date(f.cursor)}`);82 const limit = Math.min(f.limit ?? 60, 200);83 const rows = await getDb()84 .select()85 .from(conversations)86 .where(and(...conds))87 .orderBy(desc(conversations.pinned), desc(conversations.updatedAt))88 .limit(limit + 1);89 const hasMore = rows.length > limit;90 const page = rows.slice(0, limit).map(toPublicConversation);91 return { conversations: page, nextCursor: hasMore ? page[page.length - 1].updatedAt : null };92}9394export async function getConversationWithMessages(userId: string, id: string) {95 const db = getDb();96 const [c] = await db97 .select()98 .from(conversations)99 .where(and(eq(conversations.id, id), eq(conversations.userId, userId)))100 .limit(1);101 if (!c) throw new ApiError(404, "Conversation not found", "NOT_FOUND");102 const rows = await db.select().from(messages).where(eq(messages.conversationId, id)).orderBy(asc(messages.createdAt));103 return { conversation: toPublicConversation(c), messages: rows.map(toPublicMessage) };104}105106export async function createConversation(userId: string, input: { title?: string; modelKey?: string | null; systemPrompt?: string | null; folderId?: string | null; projectId?: string | null; settings?: Record<string, unknown> }) {107 const [c] = await getDb()108 .insert(conversations)109 .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 ?? {} })110 .returning();111 return toPublicConversation(c);112}113114export 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<string, unknown>; modelKey?: string | null }) {115 const set: Partial<typeof conversations.$inferInsert> = { updatedAt: new Date() };116 if (patch.title !== undefined) {117 set.title = patch.title.trim().slice(0, 200) || "New chat";118 set.titleSource = "user";119 }120 if (patch.pinned !== undefined) set.pinned = patch.pinned;121 if (patch.archived !== undefined) set.archived = patch.archived;122 if (patch.folderId !== undefined) set.folderId = patch.folderId;123 if (patch.projectId !== undefined) set.projectId = patch.projectId;124 if (patch.systemPrompt !== undefined) set.systemPrompt = patch.systemPrompt;125 if (patch.settings !== undefined) set.settings = patch.settings;126 if (patch.modelKey !== undefined) {127 set.modelKey = patch.modelKey;128 set.provider = (patch.modelKey?.split("/")[0] as ProviderId | undefined) ?? null;129 }130 const [c] = await getDb()131 .update(conversations)132 .set(set)133 .where(and(eq(conversations.id, id), eq(conversations.userId, userId)))134 .returning();135 if (!c) throw new ApiError(404, "Conversation not found", "NOT_FOUND");136 return toPublicConversation(c);137}138139export async function deleteConversation(userId: string, id: string) {140 const res = await getDb()141 .delete(conversations)142 .where(and(eq(conversations.id, id), eq(conversations.userId, userId)))143 .returning({ id: conversations.id });144 if (!res.length) throw new ApiError(404, "Conversation not found", "NOT_FOUND");145}146147export async function deleteMessage(userId: string, conversationId: string, messageId: string) {148 const db = getDb();149 const res = await db150 .delete(messages)151 .where(and(eq(messages.id, messageId), eq(messages.conversationId, conversationId), eq(messages.userId, userId)))152 .returning({ id: messages.id });153 if (!res.length) throw new ApiError(404, "Message not found", "NOT_FOUND");154 await db155 .update(conversations)156 .set({ messageCount: sql`(select count(*)::int from ${messages} where ${messages.conversationId} = ${conversationId} and ${messages.active} = true)`, updatedAt: new Date() })157 .where(eq(conversations.id, conversationId));158}159160/** Copy a conversation (active messages only). When `uptoMessageId` is given → branch. */161export async function duplicateConversation(userId: string, id: string, opts: { uptoMessageId?: string; title?: string } = {}) {162 const db = getDb();163 const [src] = await db164 .select()165 .from(conversations)166 .where(and(eq(conversations.id, id), eq(conversations.userId, userId)))167 .limit(1);168 if (!src) throw new ApiError(404, "Conversation not found", "NOT_FOUND");169 let rows = await db170 .select()171 .from(messages)172 .where(and(eq(messages.conversationId, id), eq(messages.active, true)))173 .orderBy(asc(messages.createdAt));174 if (opts.uptoMessageId) {175 const idx = rows.findIndex((r) => r.id === opts.uptoMessageId);176 if (idx < 0) throw new ApiError(404, "Message not found", "NOT_FOUND");177 rows = rows.slice(0, idx + 1);178 }179 const isBranch = Boolean(opts.uptoMessageId);180 const [created] = await db181 .insert(conversations)182 .values({183 id: ids.conversation(),184 userId,185 title: opts.title ?? (isBranch ? `${src.title} (branch)` : `${src.title} (copy)`),186 titleSource: "user",187 folderId: src.folderId,188 modelKey: src.modelKey,189 provider: src.provider,190 systemPrompt: src.systemPrompt,191 settings: src.settings,192 parentConversationId: isBranch ? src.id : null,193 branchedFromMessageId: opts.uptoMessageId ?? null,194 messageCount: rows.length,195 totalCostUsd: rows.reduce((s, r) => s + (r.costUsd ?? 0), 0),196 totalInputTokens: rows.reduce((s, r) => s + ((r.usage as { inputTokens?: number } | null)?.inputTokens ?? 0), 0),197 totalOutputTokens: rows.reduce((s, r) => s + ((r.usage as { outputTokens?: number } | null)?.outputTokens ?? 0), 0),198 lastMessageAt: rows.length ? rows[rows.length - 1].createdAt : null,199 })200 .returning();201 if (rows.length) {202 const idMap = new Map<string, string>();203 const values = rows.map((r) => {204 const nid = ids.message();205 idMap.set(r.id, nid);206 return { ...r, id: nid, conversationId: created.id, parentMessageId: null, version: 1, active: true, createdAt: r.createdAt, updatedAt: new Date() };207 });208 await db.insert(messages).values(values);209 // duplicate attachment rows so deleting the original does not orphan the copy210 const attIds = rows.flatMap((r) => (r.parts as StoredPart[]).filter((p) => p.type === "attachment").map((p) => (p as { attachmentId: string }).attachmentId));211 if (attIds.length) {212 const atts = await db.select().from(messageAttachments).where(inArray(messageAttachments.id, attIds));213 for (const a of atts) {214 const newAttId = ids.attachment();215 const newMsgId = a.messageId ? idMap.get(a.messageId) ?? null : null;216 await db.insert(messageAttachments).values({ ...a, id: newAttId, messageId: newMsgId, conversationId: created.id, createdAt: new Date() });217 // rewrite references in parts218 for (const v of values) {219 v.parts = (v.parts as StoredPart[]).map((p) => (p.type === "attachment" && p.attachmentId === a.id ? { ...p, attachmentId: newAttId } : p));220 }221 }222 for (const v of values) await db.update(messages).set({ parts: v.parts }).where(eq(messages.id, v.id));223 }224 }225 return toPublicConversation(created);226}227228// --- folders ------------------------------------------------------------------229export async function listFolders(userId: string) {230 return getDb().select().from(folders).where(eq(folders.userId, userId)).orderBy(asc(folders.sortOrder), asc(folders.name));231}232export async function createFolder(userId: string, name: string, color?: string | null) {233 const [f] = await getDb().insert(folders).values({ id: ids.folder(), userId, name: name.trim().slice(0, 80), color: color ?? null }).returning();234 return f;235}236export async function updateFolder(userId: string, id: string, patch: { name?: string; color?: string | null; sortOrder?: number }) {237 const [f] = await getDb()238 .update(folders)239 .set({ ...(patch.name !== undefined ? { name: patch.name.trim().slice(0, 80) } : {}), ...(patch.color !== undefined ? { color: patch.color } : {}), ...(patch.sortOrder !== undefined ? { sortOrder: patch.sortOrder } : {}) })240 .where(and(eq(folders.id, id), eq(folders.userId, userId)))241 .returning();242 if (!f) throw new ApiError(404, "Folder not found", "NOT_FOUND");243 return f;244}245export async function deleteFolder(userId: string, id: string) {246 await getDb()247 .delete(folders)248 .where(and(eq(folders.id, id), eq(folders.userId, userId)));249}250251// --- search ---------------------------------------------------------------------252export async function searchEverything(userId: string, q: string, limit = 20) {253 const db = getDb();254 const term = `%${q.replace(/[%_]/g, "\\$&")}%`;255 const convs = await db256 .select({ id: conversations.id, title: conversations.title, updatedAt: conversations.updatedAt, modelKey: conversations.modelKey })257 .from(conversations)258 .where(and(eq(conversations.userId, userId), ilike(conversations.title, term)))259 .orderBy(desc(conversations.updatedAt))260 .limit(limit);261 const msgs = await db262 .select({ id: messages.id, conversationId: messages.conversationId, role: messages.role, content: messages.content, createdAt: messages.createdAt, title: conversations.title })263 .from(messages)264 .innerJoin(conversations, eq(messages.conversationId, conversations.id))265 .where(and(eq(messages.userId, userId), eq(messages.active, true), or(ilike(messages.content, term))))266 .orderBy(desc(messages.createdAt))267 .limit(limit);268 return {269 conversations: convs.map((c) => ({ ...c, updatedAt: c.updatedAt.toISOString() })),270 messages: msgs.map((m) => ({ ...m, createdAt: m.createdAt.toISOString(), snippet: snippet(m.content, q) })),271 };272}273274function snippet(text: string, q: string, span = 90): string {275 const idx = text.toLowerCase().indexOf(q.toLowerCase());276 if (idx < 0) return text.slice(0, span);277 const start = Math.max(0, idx - span / 2);278 return (start > 0 ? "…" : "") + text.slice(start, start + span) + (start + span < text.length ? "…" : "");279}280281// --- export -----------------------------------------------------------------------282export const EXPORT_FORMATS = ["json", "markdown", "txt", "html"] as const;283export type ExportFormat = (typeof EXPORT_FORMATS)[number];284285export interface ExportResult {286 filename: string;287 contentType: string;288 body: string;289}290291/**292 * Export a conversation (active branch only).293 * - `json` machine-readable, includes conversation metadata and message parts294 * - `markdown` human-readable transcript295 * - `txt` plain-text transcript (no markup)296 * - `html` self-contained, print-optimized document (used for "PDF (print)"; `opts.print` auto-opens the dialog)297 */298export async function exportConversation(userId: string, id: string, format: ExportFormat, opts: { print?: boolean; appUrl?: string } = {}): Promise<ExportResult> {299 const { conversation, messages: msgs } = await getConversationWithMessages(userId, id);300 const active = msgs.filter((m) => (m as unknown as { active?: boolean }).active !== false);301 const exportedAt = new Date().toISOString();302 const base = slug(conversation.title);303304 if (format === "json") return { filename: `${base}.json`, contentType: "application/json", body: JSON.stringify({ exportedAt, app: "PolyLLM", conversation, messages: active }, null, 2) };305306 if (format === "markdown") {307 const lines = [`# ${conversation.title}`, "", `_Exported from PolyLLM on ${exportedAt.slice(0, 10)}_`, conversation.systemPrompt ? `\n> **System prompt:** ${conversation.systemPrompt}` : "", ""];308 for (const m of active) {309 const who = m.role === "user" ? "You" : m.role === "assistant" ? `Assistant (${m.modelKey ?? "model"})` : m.role;310 lines.push(`## ${who}`, "");311 for (const p of m.parts) {312 if (p.type === "reasoning" && p.text) lines.push("<details><summary>Reasoning</summary>", "", p.text, "", "</details>", "");313 else if (p.type === "attachment") lines.push(`_Attachment: ${p.name} (${p.mimeType}, ${p.sizeBytes} bytes)_`, "");314 else if (p.type === "tool-call") lines.push(`**Tool call** \`${p.name}\`(${JSON.stringify(p.arguments)}) → ${JSON.stringify(p.result ?? null)}`, "");315 }316 lines.push(m.content, "");317 }318 return { filename: `${base}.md`, contentType: "text/markdown; charset=utf-8", body: lines.join("\n") };319 }320321 if (format === "txt") {322 const rule = "─".repeat(60);323 const lines = [conversation.title.toUpperCase(), `Exported from PolyLLM · ${exportedAt.replace("T", " ").slice(0, 16)} UTC`, `${active.length} message${active.length === 1 ? "" : "s"}`, rule, ""];324 if (conversation.systemPrompt) lines.push("SYSTEM PROMPT", conversation.systemPrompt, "", rule, "");325 for (const m of active) {326 const who = m.role === "user" ? "You" : m.role === "assistant" ? `Assistant · ${m.modelKey ?? "model"}` : m.role;327 const when = new Date(m.createdAt).toISOString().replace("T", " ").slice(0, 16);328 lines.push(`[${when}] ${who}`, "");329 const attachments = m.parts.filter((p): p is Extract<StoredPart, { type: "attachment" }> => p.type === "attachment");330 for (const a of attachments) lines.push(`(attachment: ${a.name}, ${a.mimeType}, ${a.sizeBytes} bytes)`);331 const tools = m.parts.filter((p): p is Extract<StoredPart, { type: "tool-call" }> => p.type === "tool-call");332 for (const t of tools) lines.push(`(tool call: ${t.name} ${t.argumentsText ?? JSON.stringify(t.arguments)})`);333 lines.push(m.content.trim() || "(no text)", "", rule, "");334 }335 return { filename: `${base}.txt`, contentType: "text/plain; charset=utf-8", body: lines.join("\n") };336 }337338 // html339 const { buildExportHtml } = await import("@/lib/export/html-document");340 const doc = buildExportHtml({341 title: conversation.title,342 createdAt: conversation.createdAt,343 exportedAt,344 systemPrompt: conversation.systemPrompt,345 appUrl: opts.appUrl ?? "https://www.polyllm.io",346 autoPrint: Boolean(opts.print),347 totals: { costUsd: conversation.totalCostUsd, inputTokens: conversation.totalInputTokens, outputTokens: conversation.totalOutputTokens },348 messages: active.map((m) => ({349 role: m.role,350 content: m.content,351 modelKey: m.modelKey,352 createdAt: m.createdAt,353 reasoning: m.parts354 .filter((p): p is Extract<StoredPart, { type: "reasoning" }> => p.type === "reasoning")355 .map((p) => p.text)356 .join("\n\n"),357 tools: m.parts.filter((p): p is Extract<StoredPart, { type: "tool-call" }> => p.type === "tool-call").map((t) => ({ name: t.name, argumentsText: t.argumentsText ?? JSON.stringify(t.arguments), isError: t.isError })),358 attachments: m.parts.filter((p): p is Extract<StoredPart, { type: "attachment" }> => p.type === "attachment").map((a) => ({ name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes })),359 citations: m.parts.filter((p): p is Extract<StoredPart, { type: "citation" }> => p.type === "citation").map((c) => ({ url: c.url, title: c.title })),360 usage: m.usage as { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null,361 costUsd: m.costUsd,362 latencyMs: m.latencyMs,363 })),364 });365 return { filename: `${base}.html`, contentType: "text/html; charset=utf-8", body: doc };366}367368function slug(s: string): string {369 return (370 s371 .normalize("NFD")372 .replace(/[̀-ͯ]/g, "")373 .toLowerCase()374 .replace(/[^a-z0-9]+/g, "-")375 .replace(/^-+|-+$/g, "")376 .slice(0, 60) || "conversation"377 );378}379380// --- sharing ------------------------------------------------------------------------381/**382 * First element of a share snapshot: metadata about the selection. Message renderers skip it because it has383 * no `role` (see `parseSnapshot` in app/share/[id]/page.tsx).384 */385export interface ShareSnapshotMeta {386 $meta: true;387 version: 2;388 partial: boolean;389 selectedCount: number;390 totalCount: number;391 generatedAt: string;392}393394export function readShareMeta(snapshot: unknown): ShareSnapshotMeta | null {395 if (!Array.isArray(snapshot) || !snapshot.length) return null;396 const first = snapshot[0] as Partial<ShareSnapshotMeta> | null;397 return first && typeof first === "object" && first.$meta === true ? (first as ShareSnapshotMeta) : null;398}399400/**401 * Create (or refresh) a public share link.402 * - Without `messageIds`: one "entire conversation" link per conversation — an existing active full link is403 * refreshed in place (same URL keeps working).404 * - With `messageIds`: snapshots only the selected active messages, in conversation order, as a NEW link.405 * Attachments are never included (replaced by a `[attachment name]` text part).406 */407export async function shareConversation(userId: string, id: string, opts: { messageIds?: string[] } = {}) {408 const db = getDb();409 const { conversation, messages: msgs } = await getConversationWithMessages(userId, id);410 const active = msgs.filter((m) => m.status !== "streaming" && (m as unknown as { active?: boolean }).active !== false);411 const wanted = opts.messageIds?.length ? new Set(opts.messageIds) : null;412 const selected = wanted ? active.filter((m) => wanted.has(m.id)) : active;413 if (wanted && !selected.length) throw new ApiError(400, "None of the selected messages exist in this conversation", "EMPTY_SELECTION");414 const meta: ShareSnapshotMeta = { $meta: true, version: 2, partial: Boolean(wanted) && selected.length < active.length, selectedCount: selected.length, totalCount: active.length, generatedAt: new Date().toISOString() };415 const snapshot: unknown[] = [416 meta,417 ...selected.map((m) => ({418 role: m.role,419 content: m.content,420 modelKey: m.modelKey,421 createdAt: m.createdAt,422 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"),423 usage: m.usage,424 latencyMs: m.latencyMs,425 })),426 ];427 if (!meta.partial) {428 const existing = await db429 .select()430 .from(sharedConversations)431 .where(and(eq(sharedConversations.conversationId, id), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt)))432 .orderBy(asc(sharedConversations.createdAt));433 const full = existing.find((s) => !(readShareMeta(s.snapshot)?.partial ?? false));434 if (full) {435 await db.update(sharedConversations).set({ snapshot, title: conversation.title }).where(eq(sharedConversations.id, full.id));436 return { id: full.id, partial: false, messageCount: selected.length, created: false };437 }438 }439 const shareId = ids.share();440 await db.insert(sharedConversations).values({ id: shareId, conversationId: id, userId, title: conversation.title, snapshot });441 return { id: shareId, partial: meta.partial, messageCount: selected.length, created: true };442}443444/** Revoke every active link of a conversation. */445export async function revokeShare(userId: string, conversationId: string) {446 await getDb()447 .update(sharedConversations)448 .set({ revokedAt: new Date() })449 .where(and(eq(sharedConversations.conversationId, conversationId), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt)));450}451452/** Revoke one link by its public id. */453export async function revokeShareById(userId: string, shareId: string) {454 const res = await getDb()455 .update(sharedConversations)456 .set({ revokedAt: new Date() })457 .where(and(eq(sharedConversations.id, shareId), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt)))458 .returning({ id: sharedConversations.id });459 if (!res.length) throw new ApiError(404, "Share link not found", "NOT_FOUND");460}461462export interface ShareLinkItem {463 id: string;464 conversationId: string;465 title: string;466 createdAt: string;467 viewCount: number;468 /** Number of messages in the snapshot. */469 messageCount: number;470 /** True when only selected messages were shared. */471 partial: boolean;472 /** Relative path — prefix with the origin on the client. */473 path: string;474}475476function toShareItem(row: typeof sharedConversations.$inferSelect): ShareLinkItem {477 const meta = readShareMeta(row.snapshot);478 const count = meta ? meta.selectedCount : Array.isArray(row.snapshot) ? row.snapshot.length : 0;479 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}` };480}481482/** All active links of a user (Settings → Data), newest first. Optionally scoped to a conversation. */483export async function listShares(userId: string, conversationId?: string): Promise<ShareLinkItem[]> {484 const conds = [eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt)];485 if (conversationId) conds.push(eq(sharedConversations.conversationId, conversationId));486 const rows = await getDb()487 .select()488 .from(sharedConversations)489 .where(and(...conds))490 .orderBy(desc(sharedConversations.createdAt))491 .limit(200);492 return rows.map(toShareItem);493}494495export async function getShare(userId: string, conversationId: string) {496 const [row] = await getDb()497 .select({ id: sharedConversations.id, createdAt: sharedConversations.createdAt, viewCount: sharedConversations.viewCount })498 .from(sharedConversations)499 .where(and(eq(sharedConversations.conversationId, conversationId), eq(sharedConversations.userId, userId), isNull(sharedConversations.revokedAt)))500 .limit(1);501 return row ?? null;502}503504/** Public read. Increments `viewCount` unless `peek` (used by `generateMetadata` so a page view counts once). */505export async function getPublicShare(shareId: string, opts: { peek?: boolean } = {}) {506 const db = getDb();507 const [row] = await db508 .select()509 .from(sharedConversations)510 .where(and(eq(sharedConversations.id, shareId), isNull(sharedConversations.revokedAt), eq(sharedConversations.isPublic, true)))511 .limit(1);512 if (!row) return null;513 if (!opts.peek) await db.update(sharedConversations).set({ viewCount: sql`${sharedConversations.viewCount} + 1` }).where(eq(sharedConversations.id, shareId));514 return row;515}516