import { guarded, json } from "@/lib/api"; import { sql, vec } from "@/lib/db"; import { openaiEmbed } from "@/lib/ai/openai"; /** Full-text + semantic search across messages; filters: model, project, from, to, role. */ export const GET = guarded(async (req) => { const p = req.nextUrl.searchParams; const q = (p.get("q") ?? "").trim(); if (q.length < 2) return json({ results: [] }); const model = p.get("model"); const project = p.get("project"); const from = p.get("from"); const to = p.get("to"); const semantic = p.get("semantic") !== "0"; let qv: number[] | null = null; if (semantic) { try { qv = (await openaiEmbed([q])).vectors[0]; } catch { qv = null; } } const rows = await sql` SELECT m.id, m.conversation_id, m.role, m.model_id, m.created_at, c.title, c.project_id, ts_headline('simple', left(m.text, 4000), plainto_tsquery('simple', ${q}), 'MaxFragments=2, MaxWords=25, MinWords=8') AS snippet, ts_rank(m.tsv, plainto_tsquery('simple', ${q})) AS fts, ${qv ? sql`coalesce(1 - (e.embedding <=> ${vec(qv)}::vector), 0)` : sql`0`} AS sem FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN message_embeddings e ON e.message_id = m.id WHERE m.active AND m.text <> '' AND (m.tsv @@ plainto_tsquery('simple', ${q}) OR m.text ILIKE ${"%" + q + "%"} ${qv ? sql`OR (e.embedding IS NOT NULL AND 1 - (e.embedding <=> ${vec(qv)}::vector) > 0.45)` : sql``}) AND (${!model} OR m.model_id = ${model ?? ""}) AND (${!project} OR c.project_id = ${project ?? ""}) AND (${!from} OR m.created_at >= ${from ?? "1970-01-01"}::timestamptz) AND (${!to} OR m.created_at <= ${to ?? "2100-01-01"}::timestamptz) ORDER BY (ts_rank(m.tsv, plainto_tsquery('simple', ${q})) * 0.6 + ${qv ? sql`coalesce(1 - (e.embedding <=> ${vec(qv)}::vector), 0)` : sql`0`} * 0.4) DESC, m.created_at DESC LIMIT 40`; return json({ results: rows }); });