import "server-only"; import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm"; import { getDb, conversations, messages, folders, projects, prompts, promptPresets, modelPresets, sharedConversations } from "@/db"; import { listRegistryModels } from "@/lib/ai/registry"; import { PROVIDER_IDS, type ProviderId } from "@/lib/ai/core/types"; import { parseQuery, makeSnippet, escapeLike, prefixTsQuery, type ParsedQuery, type FilterToken } from "./query"; /* ------------------------------------------------------------------------------------------------ * Public shapes (re-exported for the client through src/lib/client/types.ts) * ---------------------------------------------------------------------------------------------- */ export const SEARCH_GROUPS = ["conversations", "messages", "models", "prompts", "projects", "presets"] as const; export type SearchGroup = (typeof SEARCH_GROUPS)[number]; export interface SearchConversationHit { id: string; title: string; modelKey: string | null; provider: string | null; projectId: string | null; folderId: string | null; pinned: boolean; archived: boolean; shared: boolean; messageCount: number; updatedAt: string; lastMessageAt: string | null; } export interface SearchMessageHit { id: string; conversationId: string; title: string; role: string; modelKey: string | null; createdAt: string; /** Plain-text excerpt centred on the first matching term (highlight client-side with `query.terms`). */ snippet: string; } export interface SearchModelHit { key: string; displayName: string; provider: string; status: string; } export interface SearchPromptHit { id: string; name: string; description: string | null; /** `library` = prompts table (workspace upgrade), `legacy` = prompt_presets. */ kind: "library" | "legacy"; } export interface SearchProjectHit { id: string; name: string; description: string | null; icon: string | null; color: string | null; } export interface SearchPresetHit { id: string; name: string; description: string | null; modelKey: string; } export interface SearchResponse { query: { raw: string; text: string; terms: string[]; tokens: FilterToken[]; hasFilters: boolean; }; conversations: SearchConversationHit[]; messages: SearchMessageHit[]; models: SearchModelHit[]; prompts: SearchPromptHit[]; projects: SearchProjectHit[]; presets: SearchPresetHit[]; nextCursor: string | null; /** Which engine produced the message hits (diagnostics / QA). */ engine: "fts" | "ilike" | "none"; tookMs: number; } export interface SearchOptions { limit?: number; cursor?: string | null; groups?: SearchGroup[]; now?: Date; } /* ------------------------------------------------------------------------------------------------ * Cursor (opaque, base64url JSON): { c?: updatedAt ISO | "done", m?: createdAt ISO | "done" } * ---------------------------------------------------------------------------------------------- */ interface Cursor { c?: string; m?: string; } export function encodeCursor(c: Cursor): string { return Buffer.from(JSON.stringify(c), "utf8").toString("base64url"); } export function decodeCursor(raw: string | null | undefined): Cursor | null { if (!raw) return null; try { const v = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Cursor; if (typeof v !== "object" || v === null) return null; return { c: typeof v.c === "string" ? v.c : undefined, m: typeof v.m === "string" ? v.m : undefined }; } catch { return null; } } function cursorDate(v: string | undefined): Date | null { if (!v || v === "done") return null; const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d; } /* ------------------------------------------------------------------------------------------------ * Filter resolution * ---------------------------------------------------------------------------------------------- */ const PROVIDER_ALIASES: Record = { google: "gemini", grok: "xai", claude: "anthropic", gpt: "openai", moonshot: "kimi", "open router": "openrouter", local: "custom", ollama: "custom" }; function resolveProviders(values: string[]): ProviderId[] { const out = new Set(); for (const raw of values) { const v = raw.toLowerCase(); if (PROVIDER_ALIASES[v]) out.add(PROVIDER_ALIASES[v]); for (const p of PROVIDER_IDS) if (p.includes(v) || v.includes(p)) out.add(p); } return Array.from(out); } async function resolveProjectIds(userId: string, values: string[]): Promise { if (!values.length) return []; const conds = values.flatMap((v) => [eq(projects.id, v), ilike(projects.name, `%${escapeLike(v)}%`)]); const rows = await getDb() .select({ id: projects.id }) .from(projects) .where(and(eq(projects.userId, userId), or(...conds))) .limit(50); return rows.map((r) => r.id); } async function resolveFolderIds(userId: string, values: string[]): Promise { if (!values.length) return []; const conds = values.flatMap((v) => [eq(folders.id, v), ilike(folders.name, `%${escapeLike(v)}%`)]); const rows = await getDb() .select({ id: folders.id }) .from(folders) .where(and(eq(folders.userId, userId), or(...conds))) .limit(50); return rows.map((r) => r.id); } async function resolveModelKeys(values: string[]): Promise { if (!values.length) return []; const all = await listRegistryModels({ includeDeprecated: true, includeHidden: true }); const keys = new Set(); for (const v of values) { for (const m of all) if (m.displayName.toLowerCase().includes(v) || m.key.toLowerCase().includes(v) || m.id.toLowerCase().includes(v)) keys.add(m.key); } return Array.from(keys); } interface Resolved { providers: ProviderId[] | null; // null = no filter projectIds: string[] | null; folderIds: string[] | null; modelKeys: string[]; // extra keys resolved from display names impossible: boolean; // a filter matched nothing → empty result } async function resolveFilters(userId: string, q: ParsedQuery): Promise { const f = q.filters; const [projectIds, folderIds, modelKeys] = await Promise.all([f.project.length ? resolveProjectIds(userId, f.project) : Promise.resolve(null), f.folder.length ? resolveFolderIds(userId, f.folder) : Promise.resolve(null), resolveModelKeys(f.model)]); const providers = f.provider.length ? resolveProviders(f.provider) : null; const impossible = (projectIds !== null && projectIds.length === 0) || (folderIds !== null && folderIds.length === 0) || (providers !== null && providers.length === 0); return { providers, projectIds, folderIds, modelKeys, impossible }; } /* ------------------------------------------------------------------------------------------------ * SQL fragments * ---------------------------------------------------------------------------------------------- */ const sharedExists = sql`exists (select 1 from ${sharedConversations} s where s.conversation_id = ${conversations.id} and s.revoked_at is null)`; const convActivity = sql`coalesce(${conversations.lastMessageAt}, ${conversations.updatedAt})`; // Must match the expressions indexed by drizzle/0003_search_index.sql exactly. const msgTsv = sql`to_tsvector('simple', coalesce(${messages.content}, ''))`; const titleTsv = sql`to_tsvector('simple', coalesce(${conversations.title}, ''))`; function modelCond(col: SQL, values: string[], extraKeys: string[]): SQL | null { if (!values.length) return null; const parts: SQL[] = values.map((v) => sql`${col} ILIKE ${`%${escapeLike(v)}%`}`); if (extraKeys.length) parts.push(sql`${col} in (${sql.join(extraKeys.map((k) => sql`${k}`), sql`, `)})`); return or(...parts)!; } /** Conversation-level conditions shared by both groups (the message query joins conversations). */ function conversationScope(userId: string, q: ParsedQuery, r: Resolved): SQL[] { const f = q.filters; const conds: SQL[] = [eq(conversations.userId, userId)]; if (f.is.includes("pinned")) conds.push(eq(conversations.pinned, true)); if (f.is.includes("archived")) conds.push(eq(conversations.archived, true)); if (f.is.includes("shared")) conds.push(sharedExists); if (r.providers) conds.push(inArray(conversations.provider, r.providers)); if (r.projectIds) conds.push(inArray(conversations.projectId, r.projectIds)); if (r.folderIds) conds.push(inArray(conversations.folderId, r.folderIds)); return conds; } function ilikeAll(col: SQL | typeof messages.content | typeof conversations.title, q: ParsedQuery): SQL | null { const needles = [...q.words, ...q.phrases]; if (!needles.length) return null; return and(...needles.map((n) => sql`${col} ILIKE ${`%${escapeLike(n)}%`}`))!; } /* ------------------------------------------------------------------------------------------------ * Groups * ---------------------------------------------------------------------------------------------- */ async function searchConversations(userId: string, q: ParsedQuery, r: Resolved, limit: number, after: Date | null) { const f = q.filters; const conds = conversationScope(userId, q, r); const mc = modelCond(sql`${conversations.modelKey}`, f.model, r.modelKeys); if (mc) conds.push(mc); if (f.after) conds.push(sql`${convActivity} >= ${f.after}`); if (f.before) conds.push(sql`${convActivity} < ${f.before}`); if (q.text) { const like = ilikeAll(conversations.title, q)!; conds.push(or(like, sql`${titleTsv} @@ websearch_to_tsquery('simple', ${q.text})`)!); } if (after) conds.push(sql`${conversations.updatedAt} < ${after}`); const rows = await getDb() .select({ id: conversations.id, title: conversations.title, modelKey: conversations.modelKey, provider: conversations.provider, projectId: conversations.projectId, folderId: conversations.folderId, pinned: conversations.pinned, archived: conversations.archived, messageCount: conversations.messageCount, updatedAt: conversations.updatedAt, lastMessageAt: conversations.lastMessageAt, shared: sharedExists, }) .from(conversations) .where(and(...conds)) .orderBy(desc(conversations.updatedAt)) .limit(limit + 1); const hasMore = rows.length > limit; const page: SearchConversationHit[] = rows.slice(0, limit).map((c) => ({ id: c.id, title: c.title, modelKey: c.modelKey, provider: c.provider, projectId: c.projectId, folderId: c.folderId, pinned: c.pinned, archived: c.archived, shared: Boolean(c.shared), messageCount: c.messageCount, updatedAt: c.updatedAt.toISOString(), lastMessageAt: c.lastMessageAt?.toISOString() ?? null, })); return { page, hasMore }; } async function searchMessages(userId: string, q: ParsedQuery, r: Resolved, limit: number, after: Date | null): Promise<{ page: SearchMessageHit[]; hasMore: boolean; engine: SearchResponse["engine"] }> { const f = q.filters; const base: SQL[] = [eq(messages.userId, userId), eq(messages.active, true), sql`${messages.status} <> 'streaming'`, ...conversationScope(userId, q, r)]; if (f.role) base.push(eq(messages.role, f.role)); const mc = modelCond(sql`coalesce(${messages.modelKey}, ${conversations.modelKey})`, f.model, r.modelKeys); if (mc) base.push(mc); if (f.after) base.push(sql`${messages.createdAt} >= ${f.after}`); if (f.before) base.push(sql`${messages.createdAt} < ${f.before}`); if (after) base.push(sql`${messages.createdAt} < ${after}`); const run = async (textCond: SQL | null) => { const conds = textCond ? [...base, textCond] : base; return getDb() .select({ id: messages.id, conversationId: messages.conversationId, role: messages.role, modelKey: messages.modelKey, content: messages.content, createdAt: messages.createdAt, title: conversations.title }) .from(messages) .innerJoin(conversations, eq(messages.conversationId, conversations.id)) .where(and(...conds)) .orderBy(desc(messages.createdAt)) .limit(limit + 1); }; let engine: SearchResponse["engine"] = "none"; let rows: Awaited> = []; if (q.text) { const prefix = prefixTsQuery(q.words); const fts = prefix && !q.phrases.length ? or(sql`${msgTsv} @@ websearch_to_tsquery('simple', ${q.text})`, sql`${msgTsv} @@ to_tsquery('simple', ${prefix})`)! : sql`${msgTsv} @@ websearch_to_tsquery('simple', ${q.text})`; rows = await run(fts); engine = "fts"; if (rows.length === 0 && !after) { // Partial words inside tokens (e.g. "config" in "reconfigure") — bounded per-user ILIKE fallback. rows = await run(ilikeAll(messages.content, q)); engine = rows.length ? "ilike" : "fts"; } } else if (f.role || f.model.length || f.after || f.before) { rows = await run(null); engine = "none"; } else { return { page: [], hasMore: false, engine }; } const hasMore = rows.length > limit; const page: SearchMessageHit[] = rows.slice(0, limit).map((m) => ({ id: m.id, conversationId: m.conversationId, title: m.title, role: m.role, modelKey: m.modelKey, createdAt: m.createdAt.toISOString(), snippet: makeSnippet(m.content, q.terms), })); return { page, hasMore, engine }; } async function searchModels(q: ParsedQuery, r: Resolved, limit = 8): Promise { if (!q.words.length && !q.phrases.length) return []; const all = await listRegistryModels(); const needles = [...q.words, ...q.phrases.map((p) => p.toLowerCase())]; return all .filter((m) => !r.providers || r.providers.includes(m.provider)) .filter((m) => { const hay = `${m.displayName} ${m.key} ${m.provider} ${m.family ?? ""}`.toLowerCase(); return needles.every((n) => hay.includes(n)); }) .slice(0, limit) .map((m) => ({ key: m.key, displayName: m.displayName, provider: m.provider, status: m.status })); } async function searchPrompts(userId: string, q: ParsedQuery, limit = 6): Promise { if (!q.text) return []; const db = getDb(); const needles = [...q.words, ...q.phrases]; const like = (col: typeof prompts.name | typeof prompts.description | typeof prompts.content) => and(...needles.map((n) => ilike(col, `%${escapeLike(n)}%`)))!; const [lib, legacy] = await Promise.all([ db .select({ id: prompts.id, name: prompts.name, description: prompts.description }) .from(prompts) .where(and(eq(prompts.userId, userId), or(like(prompts.name), like(prompts.description), like(prompts.content)))) .orderBy(desc(prompts.favorite), desc(prompts.updatedAt)) .limit(limit), db .select({ id: promptPresets.id, name: promptPresets.name, description: promptPresets.description }) .from(promptPresets) .where(and(eq(promptPresets.userId, userId), or(and(...needles.map((n) => ilike(promptPresets.name, `%${escapeLike(n)}%`))), and(...needles.map((n) => ilike(promptPresets.systemPrompt, `%${escapeLike(n)}%`)))))) .limit(limit), ]); return [...lib.map((p) => ({ ...p, kind: "library" as const })), ...legacy.map((p) => ({ ...p, kind: "legacy" as const }))].slice(0, limit + 2); } async function searchProjects(userId: string, q: ParsedQuery, limit = 6): Promise { if (!q.text) return []; const needles = [...q.words, ...q.phrases]; const rows = await getDb() .select({ id: projects.id, name: projects.name, description: projects.description, icon: projects.icon, color: projects.color }) .from(projects) .where(and(eq(projects.userId, userId), eq(projects.archived, false), or(and(...needles.map((n) => ilike(projects.name, `%${escapeLike(n)}%`))), and(...needles.map((n) => ilike(projects.description, `%${escapeLike(n)}%`)))))) .orderBy(desc(projects.updatedAt)) .limit(limit); return rows; } async function searchPresets(userId: string, q: ParsedQuery, limit = 6): Promise { if (!q.text) return []; const needles = [...q.words, ...q.phrases]; return getDb() .select({ id: modelPresets.id, name: modelPresets.name, description: modelPresets.description, modelKey: modelPresets.modelKey }) .from(modelPresets) .where(and(eq(modelPresets.userId, userId), or(and(...needles.map((n) => ilike(modelPresets.name, `%${escapeLike(n)}%`))), and(...needles.map((n) => ilike(modelPresets.description, `%${escapeLike(n)}%`)))))) .limit(limit); } /* ------------------------------------------------------------------------------------------------ * Entry point * ---------------------------------------------------------------------------------------------- */ export const SEARCH_MIN_CHARS = 2; export function isSearchable(q: ParsedQuery): boolean { return q.text.replace(/"/g, "").trim().length >= SEARCH_MIN_CHARS || q.hasFilters; } export function emptyResponse(q: ParsedQuery, tookMs = 0): SearchResponse { return { query: { raw: q.raw, text: q.text, terms: q.terms, tokens: q.tokens, hasFilters: q.hasFilters }, conversations: [], messages: [], models: [], prompts: [], projects: [], presets: [], nextCursor: null, engine: "none", tookMs }; } export async function searchAll(userId: string, raw: string, opts: SearchOptions = {}): Promise { const started = Date.now(); const q = parseQuery(raw, { now: opts.now }); if (!isSearchable(q)) return emptyResponse(q); const limit = Math.min(Math.max(opts.limit ?? 12, 1), 50); const groups = new Set(opts.groups?.length ? opts.groups : SEARCH_GROUPS); const cursor = decodeCursor(opts.cursor); const firstPage = !cursor; const r = await resolveFilters(userId, q); if (r.impossible) return emptyResponse(q, Date.now() - started); const wantConv = groups.has("conversations") && cursor?.c !== "done" && !q.filters.role; const wantMsg = groups.has("messages") && cursor?.m !== "done"; const [conv, msg, models, promptHits, projectHits, presetHits] = await Promise.all([ wantConv ? searchConversations(userId, q, r, limit, cursorDate(cursor?.c)) : Promise.resolve({ page: [] as SearchConversationHit[], hasMore: false }), wantMsg ? searchMessages(userId, q, r, limit, cursorDate(cursor?.m)) : Promise.resolve({ page: [] as SearchMessageHit[], hasMore: false, engine: "none" as const }), firstPage && groups.has("models") ? searchModels(q, r) : Promise.resolve([]), firstPage && groups.has("prompts") ? searchPrompts(userId, q) : Promise.resolve([]), firstPage && groups.has("projects") ? searchProjects(userId, q) : Promise.resolve([]), firstPage && groups.has("presets") ? searchPresets(userId, q) : Promise.resolve([]), ]); const nextCursor = conv.hasMore || msg.hasMore ? encodeCursor({ c: conv.hasMore ? conv.page[conv.page.length - 1].updatedAt : "done", m: msg.hasMore ? msg.page[msg.page.length - 1].createdAt : "done", }) : null; return { query: { raw: q.raw, text: q.text, terms: q.terms, tokens: q.tokens, hasFilters: q.hasFilters }, conversations: conv.page, messages: msg.page, models, prompts: promptHits, projects: projectHits, presets: presetHits, nextCursor, engine: msg.engine, tookMs: Date.now() - started, }; }