SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
25.0 KB · 516 lines typescript
Raw Blame History
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