TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import "server-only";2import { and, desc, eq, ilike, isNull, or, sql, type SQL } from "drizzle-orm";3import { getDb, prompts, projects, type Prompt } from "@/db";4import { ids } from "@/lib/ids";5import { ApiError } from "@/lib/api";6import { mergeVariables, renderTemplate, type PromptVariable } from "./variables";78export const PROMPT_KINDS = ["system", "user", "template", "structured"] as const;9export type PromptKind = (typeof PROMPT_KINDS)[number];1011export function toPublicPrompt(p: Prompt) {12 return {13 id: p.id,14 projectId: p.projectId,15 kind: (PROMPT_KINDS.includes(p.kind as PromptKind) ? p.kind : "user") as PromptKind,16 name: p.name,17 description: p.description,18 content: p.content,19 variables: (p.variables ?? []) as PromptVariable[],20 schema: p.schema ?? null,21 folder: p.folder,22 tags: p.tags ?? [],23 favorite: p.favorite,24 defaultModelKey: p.defaultModelKey,25 uses: p.uses,26 lastUsedAt: p.lastUsedAt?.toISOString() ?? null,27 createdAt: p.createdAt.toISOString(),28 updatedAt: p.updatedAt.toISOString(),29 };30}31export type PublicPrompt = ReturnType<typeof toPublicPrompt>;3233export interface PromptInput {34 kind?: PromptKind;35 name: string;36 description?: string | null;37 content: string;38 variables?: PromptVariable[];39 schema?: Record<string, unknown> | null;40 folder?: string | null;41 tags?: string[];42 favorite?: boolean;43 defaultModelKey?: string | null;44 projectId?: string | null;45}4647export interface ListPromptsFilter {48 q?: string;49 kind?: PromptKind;50 folder?: string | "none";51 tag?: string;52 favorite?: boolean;53 projectId?: string | "none";54 limit?: number;55}5657const normTags = (tags: string[] | undefined) => (tags ? Array.from(new Set(tags.map((t) => t.trim().toLowerCase().slice(0, 32)).filter(Boolean))).slice(0, 12) : undefined);5859async function assertProject(userId: string, projectId: string) {60 const [p] = await getDb()61 .select({ id: projects.id })62 .from(projects)63 .where(and(eq(projects.id, projectId), eq(projects.userId, userId)))64 .limit(1);65 if (!p) throw new ApiError(404, "Project not found", "NOT_FOUND");66}6768export async function listPrompts(userId: string, f: ListPromptsFilter = {}) {69 const conds: SQL[] = [eq(prompts.userId, userId)];70 if (f.q) {71 const term = `%${f.q.replace(/[%_]/g, "\\$&")}%`;72 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})`)!);73 }74 if (f.kind) conds.push(eq(prompts.kind, f.kind));75 if (f.folder === "none") conds.push(isNull(prompts.folder));76 else if (f.folder) conds.push(eq(prompts.folder, f.folder));77 if (f.tag) conds.push(sql`${prompts.tags} ? ${f.tag}`);78 if (f.favorite) conds.push(eq(prompts.favorite, true));79 if (f.projectId === "none") conds.push(isNull(prompts.projectId));80 else if (f.projectId) conds.push(eq(prompts.projectId, f.projectId));81 const db = getDb();82 const [rows, meta] = await Promise.all([83 db84 .select()85 .from(prompts)86 .where(and(...conds))87 .orderBy(desc(prompts.favorite), desc(prompts.updatedAt))88 .limit(Math.min(f.limit ?? 300, 500)),89 db.select({ folder: prompts.folder, tags: prompts.tags }).from(prompts).where(eq(prompts.userId, userId)),90 ]);91 const folders = Array.from(new Set(meta.map((m) => m.folder).filter((x): x is string => Boolean(x)))).sort((a, b) => a.localeCompare(b));92 const tagCount = new Map<string, number>();93 for (const m of meta) for (const t of m.tags ?? []) tagCount.set(t, (tagCount.get(t) ?? 0) + 1);94 const tags = [...tagCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => t);95 return { prompts: rows.map(toPublicPrompt), folders, tags, total: meta.length };96}9798export async function getPrompt(userId: string, id: string): Promise<Prompt> {99 const [p] = await getDb()100 .select()101 .from(prompts)102 .where(and(eq(prompts.id, id), eq(prompts.userId, userId)))103 .limit(1);104 if (!p) throw new ApiError(404, "Prompt not found", "NOT_FOUND");105 return p;106}107108export async function createPrompt(userId: string, input: PromptInput) {109 if (input.projectId) await assertProject(userId, input.projectId);110 const kind: PromptKind = input.kind ?? "user";111 const [p] = await getDb()112 .insert(prompts)113 .values({114 id: ids.prompt(),115 userId,116 projectId: input.projectId ?? null,117 kind,118 name: input.name.trim().slice(0, 120) || "Untitled prompt",119 description: input.description?.trim() || null,120 content: input.content,121 variables: mergeVariables(input.content, input.variables),122 schema: kind === "structured" ? input.schema ?? null : null,123 folder: input.folder?.trim().slice(0, 60) || null,124 tags: normTags(input.tags) ?? [],125 favorite: input.favorite ?? false,126 defaultModelKey: input.defaultModelKey ?? null,127 })128 .returning();129 return toPublicPrompt(p);130}131132export async function updatePrompt(userId: string, id: string, patch: Partial<PromptInput>) {133 const current = await getPrompt(userId, id);134 if (patch.projectId) await assertProject(userId, patch.projectId);135 const set: Partial<typeof prompts.$inferInsert> = { updatedAt: new Date() };136 if (patch.kind !== undefined) set.kind = patch.kind;137 if (patch.name !== undefined) set.name = patch.name.trim().slice(0, 120) || "Untitled prompt";138 if (patch.description !== undefined) set.description = patch.description?.trim() || null;139 if (patch.content !== undefined) set.content = patch.content;140 if (patch.content !== undefined || patch.variables !== undefined) {141 set.variables = mergeVariables(patch.content ?? current.content, patch.variables ?? (current.variables as PromptVariable[]));142 }143 const kind = (patch.kind ?? current.kind) as PromptKind;144 if (patch.schema !== undefined) set.schema = kind === "structured" ? patch.schema : null;145 else if (patch.kind !== undefined && kind !== "structured") set.schema = null;146 if (patch.folder !== undefined) set.folder = patch.folder?.trim().slice(0, 60) || null;147 if (patch.tags !== undefined) set.tags = normTags(patch.tags) ?? [];148 if (patch.favorite !== undefined) set.favorite = patch.favorite;149 if (patch.defaultModelKey !== undefined) set.defaultModelKey = patch.defaultModelKey;150 if (patch.projectId !== undefined) set.projectId = patch.projectId;151 const [p] = await getDb()152 .update(prompts)153 .set(set)154 .where(and(eq(prompts.id, id), eq(prompts.userId, userId)))155 .returning();156 if (!p) throw new ApiError(404, "Prompt not found", "NOT_FOUND");157 return toPublicPrompt(p);158}159160export async function deletePrompt(userId: string, id: string) {161 const res = await getDb()162 .delete(prompts)163 .where(and(eq(prompts.id, id), eq(prompts.userId, userId)))164 .returning({ id: prompts.id });165 if (!res.length) throw new ApiError(404, "Prompt not found", "NOT_FOUND");166}167168export interface UsePromptResult {169 prompt: PublicPrompt;170 kind: PromptKind;171 /** Rendered body. For `system` prompts this is also returned as `systemPrompt` and `text` is empty. */172 rendered: string;173 text: string;174 systemPrompt: string | null;175 schema: Record<string, unknown> | null;176 defaultModelKey: string | null;177 missing: string[];178 defaulted: string[];179}180181/** Renders the prompt with the given variables and records the use (counter + timestamp). */182export async function usePrompt(userId: string, id: string, variables: Record<string, string> = {}): Promise<UsePromptResult> {183 const p = await getPrompt(userId, id);184 const { text, missing, defaulted } = renderTemplate(p.content, variables, (p.variables ?? []) as PromptVariable[]);185 const [updated] = await getDb()186 .update(prompts)187 .set({ uses: sql`${prompts.uses} + 1`, lastUsedAt: new Date() })188 .where(and(eq(prompts.id, id), eq(prompts.userId, userId)))189 .returning();190 const pub = toPublicPrompt(updated ?? p);191 const isSystem = pub.kind === "system";192 return {193 prompt: pub,194 kind: pub.kind,195 rendered: text,196 text: isSystem ? "" : text,197 systemPrompt: isSystem ? text : null,198 schema: pub.kind === "structured" ? pub.schema : null,199 defaultModelKey: pub.defaultModelKey,200 missing,201 defaulted,202 };203}204