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%
12.4 KB · 345 lines typescript
Raw Blame History
1/**2 * Search query language — pure, dependency-free, unit-tested (tests/unit/search-query.test.ts).3 *4 *   free text  "quoted phrase"  model:claude  provider:openai  project:research  folder:clients5 *   after:2026-08-01  before:2026-09-01  after:7d  after:today  role:user|assistant  is:pinned|archived|shared6 *7 * Unknown `key:value` tokens are kept as free text so typing a URL or `10:30` still searches.8 * The same module also provides highlighting/snippet helpers shared by the API (snippets) and the UI (marks).9 */1011export const FILTER_KEYS = ["model", "provider", "project", "folder", "after", "before", "role", "is"] as const;12export type FilterKey = (typeof FILTER_KEYS)[number];1314export type SearchRole = "user" | "assistant" | "system";15export type SearchFlag = "pinned" | "archived" | "shared";1617export interface SearchFilters {18  /** Substring matched against `modelKey` and the registry display name. */19  model: string[];20  /** Provider id (normalized to lower case). */21  provider: string[];22  /** Project id or name (substring). */23  project: string[];24  /** Folder id or name (substring). */25  folder: string[];26  /** Inclusive lower bound. */27  after: Date | null;28  /** Exclusive upper bound (`before:2026-09-01` = strictly before that day). */29  before: Date | null;30  role: SearchRole | null;31  is: SearchFlag[];32}3334export interface ParsedQuery {35  /** Original input. */36  raw: string;37  /** Free text (tokens + phrases) joined by spaces, used for full-text search. */38  text: string;39  /** Individual free-text words (lower-cased, deduped, order preserved). */40  words: string[];41  /** Quoted phrases (original case). */42  phrases: string[];43  /** Words + phrases, lower-cased — what to highlight. */44  terms: string[];45  filters: SearchFilters;46  /** True when at least one filter is set. */47  hasFilters: boolean;48  /** Tokens as typed (for chips): `{ key, value, raw }`. */49  tokens: FilterToken[];50}5152export interface FilterToken {53  key: FilterKey;54  value: string;55  /** Exact substring of the raw query (used to remove the chip). */56  raw: string;57}5859const ROLE_ALIASES: Record<string, SearchRole> = { user: "user", me: "user", you: "user", human: "user", assistant: "assistant", ai: "assistant", model: "assistant", bot: "assistant", system: "system" };60const FLAGS: SearchFlag[] = ["pinned", "archived", "shared"];6162function emptyFilters(): SearchFilters {63  return { model: [], provider: [], project: [], folder: [], after: null, before: null, role: null, is: [] };64}6566/** Tokenizer: whitespace-separated, double quotes group words (also inside `key:"…"`). */67export function tokenize(input: string): string[] {68  const out: string[] = [];69  let cur = "";70  let quoted = false;71  for (let i = 0; i < input.length; i++) {72    const ch = input[i];73    if (ch === '"') {74      quoted = !quoted;75      cur += ch;76      continue;77    }78    if (!quoted && /\s/.test(ch)) {79      if (cur) out.push(cur);80      cur = "";81      continue;82    }83    cur += ch;84  }85  if (cur) out.push(cur);86  return out;87}8889const DATE_RE = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/;90const REL_RE = /^(\d{1,3})\s*(d|day|days|w|week|weeks|m|month|months|y|year|years)$/i;9192/** Parses absolute (`2026-08-01`, `2026-08`) and relative (`7d`, `2w`, `today`, `yesterday`) dates → UTC midnight. */93export function parseDateToken(value: string, now: Date, edge: "start" | "end"): Date | null {94  const v = value.trim().toLowerCase();95  if (!v) return null;96  const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());97  const day = 86_400_000;98  const abs = DATE_RE.exec(v);99  if (abs) {100    const y = Number(abs[1]);101    const mo = Number(abs[2]) - 1;102    if (mo < 0 || mo > 11) return null;103    if (abs[3]) {104      const d = Number(abs[3]);105      if (d < 1 || d > 31) return null;106      const t = Date.UTC(y, mo, d);107      return new Date(edge === "start" ? t : t + day);108    }109    // whole month110    return new Date(edge === "start" ? Date.UTC(y, mo, 1) : Date.UTC(y, mo + 1, 1));111  }112  if (v === "today") return new Date(edge === "start" ? todayUtc : todayUtc + day);113  if (v === "yesterday") return new Date(edge === "start" ? todayUtc - day : todayUtc);114  if (v === "week" || v === "thisweek") return new Date(edge === "start" ? todayUtc - 7 * day : todayUtc + day);115  if (v === "month" || v === "thismonth") return new Date(edge === "start" ? Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) : todayUtc + day);116  const rel = REL_RE.exec(v);117  if (rel) {118    const n = Number(rel[1]);119    const unit = rel[2][0];120    const days = unit === "d" ? n : unit === "w" ? n * 7 : unit === "m" ? n * 30 : n * 365;121    const t = todayUtc - days * day;122    return new Date(edge === "start" ? t : t + day);123  }124  return null;125}126127function unquote(s: string): string {128  return s.replace(/^"|"$/g, "");129}130131/**132 * Parse the query language. Pure: pass `now` for deterministic relative dates.133 */134export function parseQuery(raw: string, opts: { now?: Date } = {}): ParsedQuery {135  const now = opts.now ?? new Date();136  const filters = emptyFilters();137  const words: string[] = [];138  const phrases: string[] = [];139  const tokens: FilterToken[] = [];140  const seenWords = new Set<string>();141142  for (const tok of tokenize(raw.trim().slice(0, 500))) {143    const m = /^([a-zA-Z]+):(.+)$/.exec(tok);144    if (m && (FILTER_KEYS as readonly string[]).includes(m[1].toLowerCase())) {145      const key = m[1].toLowerCase() as FilterKey;146      const value = unquote(m[2]).trim();147      if (!value) continue;148      let accepted = true;149      switch (key) {150        case "model":151          filters.model.push(value.toLowerCase());152          break;153        case "provider":154          filters.provider.push(value.toLowerCase());155          break;156        case "project":157          filters.project.push(value);158          break;159        case "folder":160          filters.folder.push(value);161          break;162        case "after": {163          const d = parseDateToken(value, now, "start");164          if (d) filters.after = d;165          else accepted = false;166          break;167        }168        case "before": {169          const d = parseDateToken(value, now, "end");170          if (d) filters.before = d;171          else accepted = false;172          break;173        }174        case "role": {175          const r = ROLE_ALIASES[value.toLowerCase()];176          if (r) filters.role = r;177          else accepted = false;178          break;179        }180        case "is": {181          const flag = value.toLowerCase() as SearchFlag;182          if (FLAGS.includes(flag)) {183            if (!filters.is.includes(flag)) filters.is.push(flag);184          } else accepted = false;185          break;186        }187      }188      if (accepted) {189        tokens.push({ key, value, raw: tok });190        continue;191      }192      // invalid filter value → fall through and treat as free text193    }194    if (tok.startsWith('"') && tok.endsWith('"') && tok.length >= 2) {195      const p = unquote(tok).trim();196      if (p) phrases.push(p);197      continue;198    }199    const w = unquote(tok).trim();200    if (!w) continue;201    const lw = w.toLowerCase();202    if (!seenWords.has(lw)) {203      seenWords.add(lw);204      words.push(lw);205    }206  }207208  const terms = Array.from(new Set([...phrases.map((p) => p.toLowerCase()), ...words]));209  const text = [...words, ...phrases.map((p) => `"${p}"`)].join(" ");210  const hasFilters = filters.model.length > 0 || filters.provider.length > 0 || filters.project.length > 0 || filters.folder.length > 0 || filters.after !== null || filters.before !== null || filters.role !== null || filters.is.length > 0;211  return { raw, text, words, phrases, terms, filters, hasFilters, tokens };212}213214/** Single-valued keys are replaced when appended again. */215const SINGLE_KEYS: FilterKey[] = ["after", "before", "role"];216217/** Quote a filter value when it contains whitespace. */218export function formatFilterToken(key: FilterKey, value: string): string {219  const v = value.trim();220  return `${key}:${/\s/.test(v) ? `"${v.replace(/"/g, "")}"` : v}`;221}222223/** Append (or replace, for single-valued keys) a filter to a raw query string. */224export function addFilter(raw: string, key: FilterKey, value: string): string {225  const token = formatFilterToken(key, value);226  const parsed = parseQuery(raw);227  let rest = tokenize(raw.trim());228  if (SINGLE_KEYS.includes(key)) {229    const existing = new Set(parsed.tokens.filter((t) => t.key === key).map((t) => t.raw));230    rest = rest.filter((t) => !existing.has(t));231  } else if (parsed.tokens.some((t) => t.key === key && t.value.toLowerCase() === value.trim().toLowerCase())) {232    return raw.trim();233  }234  return [...rest, token].join(" ").trim();235}236237/** Remove one filter token (exact raw match) from the query. */238export function removeFilter(raw: string, token: FilterToken): string {239  const toks = tokenize(raw.trim());240  const idx = toks.indexOf(token.raw);241  if (idx >= 0) toks.splice(idx, 1);242  return toks.join(" ").trim();243}244245/** Remove every filter, keeping the free text. */246export function stripFilters(raw: string): string {247  const parsed = parseQuery(raw);248  const drop = new Set(parsed.tokens.map((t) => t.raw));249  return tokenize(raw.trim())250    .filter((t) => !drop.has(t))251    .join(" ")252    .trim();253}254255/* ------------------------------------------------------------------------------------------------256 * Highlighting & snippets257 * ---------------------------------------------------------------------------------------------- */258259export interface Segment {260  text: string;261  hit: boolean;262}263264function escapeRegExp(s: string): string {265  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");266}267268/** Split `text` into hit / non-hit segments for the given terms (case-insensitive, longest term wins). */269export function highlightSegments(text: string, terms: string[]): Segment[] {270  const clean = terms.map((t) => t.trim()).filter((t) => t.length > 0);271  if (!text || clean.length === 0) return text ? [{ text, hit: false }] : [];272  const sorted = Array.from(new Set(clean)).sort((a, b) => b.length - a.length);273  const re = new RegExp(sorted.map(escapeRegExp).join("|"), "gi");274  const out: Segment[] = [];275  let last = 0;276  for (const m of text.matchAll(re)) {277    const start = m.index ?? 0;278    if (start > last) out.push({ text: text.slice(last, start), hit: false });279    out.push({ text: m[0], hit: true });280    last = start + m[0].length;281  }282  if (last < text.length) out.push({ text: text.slice(last), hit: false });283  return out;284}285286/** Lightweight markdown → plain text for snippets (no HTML, no dependencies). */287export function stripMarkdown(md: string): string {288  return md289    .replace(/```[\w+-]*\n?/g, "")290    .replace(/^\s{0,3}#{1,6}\s+/gm, "")291    .replace(/^\s{0,3}>\s?/gm, "")292    .replace(/^\s*[-*+]\s+/gm, "")293    .replace(/^\s*\d+[.)]\s+/gm, "")294    .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")295    .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")296    .replace(/(\*\*|__)(.*?)\1/g, "$2")297    .replace(/(\*|_)(?=\S)(.*?)(?<=\S)\1/g, "$2")298    .replace(/~~(.*?)~~/g, "$1")299    .replace(/`([^`]*)`/g, "$1")300    .replace(/\|/g, " ")301    .replace(/\s+/g, " ")302    .trim();303}304305/**306 * Build a short excerpt centred on the first matching term. Returns the full (trimmed) text when it is short.307 */308export function makeSnippet(text: string, terms: string[], span = 160): string {309  const plain = stripMarkdown(text);310  if (plain.length <= span) return plain;311  const lower = plain.toLowerCase();312  let idx = -1;313  for (const t of terms) {314    const i = lower.indexOf(t.toLowerCase());315    if (i >= 0 && (idx < 0 || i < idx)) idx = i;316  }317  if (idx < 0) return plain.slice(0, span).trimEnd() + "…";318  let start = Math.max(0, idx - Math.floor(span / 3));319  // snap to a word boundary320  if (start > 0) {321    const sp = plain.lastIndexOf(" ", start);322    if (sp > 0 && start - sp < 24) start = sp + 1;323  }324  let end = Math.min(plain.length, start + span);325  if (end < plain.length) {326    const sp = plain.indexOf(" ", end);327    if (sp > 0 && sp - end < 24) end = sp;328  }329  return (start > 0 ? "…" : "") + plain.slice(start, end).trim() + (end < plain.length ? "…" : "");330}331332/** Escape `%`/`_`/`\` for ILIKE patterns. */333export function escapeLike(s: string): string {334  return s.replace(/[\\%_]/g, "\\$&");335}336337/** `to_tsquery('simple', …)` prefix expression: `'foo':* & 'bar':*` (lexemes quoted). Returns null when nothing usable. */338export function prefixTsQuery(words: string[]): string | null {339  const lexemes = words340    .map((w) => w.replace(/['\\:&|!()<>*]/g, "").trim())341    .filter((w) => w.length >= 2);342  if (!lexemes.length) return null;343  return lexemes.map((w) => `'${w}':*`).join(" & ");344}345