import "server-only"; import { and, desc, eq, ilike, isNull, or, sql, type SQL } from "drizzle-orm"; import { getDb, prompts, projects, type Prompt } from "@/db"; import { ids } from "@/lib/ids"; import { ApiError } from "@/lib/api"; import { mergeVariables, renderTemplate, type PromptVariable } from "./variables"; export const PROMPT_KINDS = ["system", "user", "template", "structured"] as const; export type PromptKind = (typeof PROMPT_KINDS)[number]; export function toPublicPrompt(p: Prompt) { return { id: p.id, projectId: p.projectId, kind: (PROMPT_KINDS.includes(p.kind as PromptKind) ? p.kind : "user") as PromptKind, name: p.name, description: p.description, content: p.content, variables: (p.variables ?? []) as PromptVariable[], schema: p.schema ?? null, folder: p.folder, tags: p.tags ?? [], favorite: p.favorite, defaultModelKey: p.defaultModelKey, uses: p.uses, lastUsedAt: p.lastUsedAt?.toISOString() ?? null, createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), }; } export type PublicPrompt = ReturnType; export interface PromptInput { kind?: PromptKind; name: string; description?: string | null; content: string; variables?: PromptVariable[]; schema?: Record | null; folder?: string | null; tags?: string[]; favorite?: boolean; defaultModelKey?: string | null; projectId?: string | null; } export interface ListPromptsFilter { q?: string; kind?: PromptKind; folder?: string | "none"; tag?: string; favorite?: boolean; projectId?: string | "none"; limit?: number; } const normTags = (tags: string[] | undefined) => (tags ? Array.from(new Set(tags.map((t) => t.trim().toLowerCase().slice(0, 32)).filter(Boolean))).slice(0, 12) : undefined); async function assertProject(userId: string, projectId: string) { const [p] = await getDb() .select({ id: projects.id }) .from(projects) .where(and(eq(projects.id, projectId), eq(projects.userId, userId))) .limit(1); if (!p) throw new ApiError(404, "Project not found", "NOT_FOUND"); } export async function listPrompts(userId: string, f: ListPromptsFilter = {}) { const conds: SQL[] = [eq(prompts.userId, userId)]; if (f.q) { const term = `%${f.q.replace(/[%_]/g, "\\$&")}%`; conds.push(or(ilike(prompts.name, term), ilike(prompts.description, term), ilike(prompts.content, term), sql`exists (select 1 from jsonb_array_elements_text(${prompts.tags}) t where t ilike ${term})`)!); } if (f.kind) conds.push(eq(prompts.kind, f.kind)); if (f.folder === "none") conds.push(isNull(prompts.folder)); else if (f.folder) conds.push(eq(prompts.folder, f.folder)); if (f.tag) conds.push(sql`${prompts.tags} ? ${f.tag}`); if (f.favorite) conds.push(eq(prompts.favorite, true)); if (f.projectId === "none") conds.push(isNull(prompts.projectId)); else if (f.projectId) conds.push(eq(prompts.projectId, f.projectId)); const db = getDb(); const [rows, meta] = await Promise.all([ db .select() .from(prompts) .where(and(...conds)) .orderBy(desc(prompts.favorite), desc(prompts.updatedAt)) .limit(Math.min(f.limit ?? 300, 500)), db.select({ folder: prompts.folder, tags: prompts.tags }).from(prompts).where(eq(prompts.userId, userId)), ]); const folders = Array.from(new Set(meta.map((m) => m.folder).filter((x): x is string => Boolean(x)))).sort((a, b) => a.localeCompare(b)); const tagCount = new Map(); for (const m of meta) for (const t of m.tags ?? []) tagCount.set(t, (tagCount.get(t) ?? 0) + 1); const tags = [...tagCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => t); return { prompts: rows.map(toPublicPrompt), folders, tags, total: meta.length }; } export async function getPrompt(userId: string, id: string): Promise { const [p] = await getDb() .select() .from(prompts) .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) .limit(1); if (!p) throw new ApiError(404, "Prompt not found", "NOT_FOUND"); return p; } export async function createPrompt(userId: string, input: PromptInput) { if (input.projectId) await assertProject(userId, input.projectId); const kind: PromptKind = input.kind ?? "user"; const [p] = await getDb() .insert(prompts) .values({ id: ids.prompt(), userId, projectId: input.projectId ?? null, kind, name: input.name.trim().slice(0, 120) || "Untitled prompt", description: input.description?.trim() || null, content: input.content, variables: mergeVariables(input.content, input.variables), schema: kind === "structured" ? input.schema ?? null : null, folder: input.folder?.trim().slice(0, 60) || null, tags: normTags(input.tags) ?? [], favorite: input.favorite ?? false, defaultModelKey: input.defaultModelKey ?? null, }) .returning(); return toPublicPrompt(p); } export async function updatePrompt(userId: string, id: string, patch: Partial) { const current = await getPrompt(userId, id); if (patch.projectId) await assertProject(userId, patch.projectId); const set: Partial = { updatedAt: new Date() }; if (patch.kind !== undefined) set.kind = patch.kind; if (patch.name !== undefined) set.name = patch.name.trim().slice(0, 120) || "Untitled prompt"; if (patch.description !== undefined) set.description = patch.description?.trim() || null; if (patch.content !== undefined) set.content = patch.content; if (patch.content !== undefined || patch.variables !== undefined) { set.variables = mergeVariables(patch.content ?? current.content, patch.variables ?? (current.variables as PromptVariable[])); } const kind = (patch.kind ?? current.kind) as PromptKind; if (patch.schema !== undefined) set.schema = kind === "structured" ? patch.schema : null; else if (patch.kind !== undefined && kind !== "structured") set.schema = null; if (patch.folder !== undefined) set.folder = patch.folder?.trim().slice(0, 60) || null; if (patch.tags !== undefined) set.tags = normTags(patch.tags) ?? []; if (patch.favorite !== undefined) set.favorite = patch.favorite; if (patch.defaultModelKey !== undefined) set.defaultModelKey = patch.defaultModelKey; if (patch.projectId !== undefined) set.projectId = patch.projectId; const [p] = await getDb() .update(prompts) .set(set) .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) .returning(); if (!p) throw new ApiError(404, "Prompt not found", "NOT_FOUND"); return toPublicPrompt(p); } export async function deletePrompt(userId: string, id: string) { const res = await getDb() .delete(prompts) .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) .returning({ id: prompts.id }); if (!res.length) throw new ApiError(404, "Prompt not found", "NOT_FOUND"); } export interface UsePromptResult { prompt: PublicPrompt; kind: PromptKind; /** Rendered body. For `system` prompts this is also returned as `systemPrompt` and `text` is empty. */ rendered: string; text: string; systemPrompt: string | null; schema: Record | null; defaultModelKey: string | null; missing: string[]; defaulted: string[]; } /** Renders the prompt with the given variables and records the use (counter + timestamp). */ export async function usePrompt(userId: string, id: string, variables: Record = {}): Promise { const p = await getPrompt(userId, id); const { text, missing, defaulted } = renderTemplate(p.content, variables, (p.variables ?? []) as PromptVariable[]); const [updated] = await getDb() .update(prompts) .set({ uses: sql`${prompts.uses} + 1`, lastUsedAt: new Date() }) .where(and(eq(prompts.id, id), eq(prompts.userId, userId))) .returning(); const pub = toPublicPrompt(updated ?? p); const isSystem = pub.kind === "system"; return { prompt: pub, kind: pub.kind, rendered: text, text: isSystem ? "" : text, systemPrompt: isSystem ? text : null, schema: pub.kind === "structured" ? pub.schema : null, defaultModelKey: pub.defaultModelKey, missing, defaulted, }; }