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%
4.9 KB · 130 lines typescript
Raw Blame History
1"use client";2import * as React from "react";3import { api, useApi } from "@/lib/client/api";4import { useDebounced, useLocalStorage } from "@/lib/client/hooks";5import type { SearchResponse } from "@/lib/client/types";6import { parseQuery, type ParsedQuery } from "@/lib/search/query";78export const SEARCH_MIN_CHARS = 2;910export function isSearchableQuery(parsed: ParsedQuery): boolean {11  return parsed.text.replace(/"/g, "").trim().length >= SEARCH_MIN_CHARS || parsed.hasFilters;12}1314export interface UseSearchOptions {15  enabled?: boolean;16  limit?: number;17  debounceMs?: number;18}1920export interface UseSearchResult {21  /** Debounced, trimmed query that produced `data`. */22  query: string;23  parsed: ParsedQuery;24  searchable: boolean;25  data: SearchResponse | null;26  /** True while the first page is loading or the debounce is pending for a searchable query. */27  loading: boolean;28  loadingMore: boolean;29  error: unknown;30  nextCursor: string | null;31  loadMore: () => Promise<void>;32  /** Searched and nothing came back. */33  isEmpty: boolean;34  total: number;35}3637export function countHits(d: SearchResponse | null | undefined): number {38  if (!d) return 0;39  return d.conversations.length + d.messages.length + d.models.length + d.prompts.length + d.projects.length + d.presets.length;40}4142function mergePages(first: SearchResponse, pages: SearchResponse[]): SearchResponse {43  if (!pages.length) return first;44  return {45    ...first,46    conversations: [...first.conversations, ...pages.flatMap((p) => p.conversations)],47    messages: [...first.messages, ...pages.flatMap((p) => p.messages)],48    nextCursor: pages[pages.length - 1].nextCursor,49  };50}5152/**53 * Debounced search against GET /api/search with cursor pagination for conversations/messages.54 * SWR dedupes identical queries (palette + sheet) and keeps the previous page while typing.55 */56export function useSearch(rawQuery: string, { enabled = true, limit = 12, debounceMs = 180 }: UseSearchOptions = {}): UseSearchResult {57  const trimmed = rawQuery.trim();58  const debounced = useDebounced(trimmed, debounceMs);59  const parsed = React.useMemo(() => parseQuery(debounced), [debounced]);60  const searchable = isSearchableQuery(parsed);61  const key = enabled && searchable ? `/api/search?q=${encodeURIComponent(debounced)}&limit=${limit}` : null;62  const { data, isLoading, error } = useApi<SearchResponse>(key, { keepPreviousData: true, dedupingInterval: 4000 });6364  const [extra, setExtra] = React.useState<{ forKey: string | null; pages: SearchResponse[] }>({ forKey: null, pages: [] });65  const [loadingMore, setLoadingMore] = React.useState(false);66  const merged = React.useMemo(() => {67    if (!data) return null;68    return mergePages(data, extra.forKey === key ? extra.pages : []);69  }, [data, extra, key]);70  const nextCursor = merged?.nextCursor ?? null;7172  const loadMore = React.useCallback(async () => {73    if (!key || !nextCursor || loadingMore) return;74    setLoadingMore(true);75    try {76      const res = await api<SearchResponse>(`${key}&cursor=${encodeURIComponent(nextCursor)}&groups=conversations,messages`);77      setExtra((prev) => ({ forKey: key, pages: [...(prev.forKey === key ? prev.pages : []), res] }));78    } finally {79      setLoadingMore(false);80    }81  }, [key, nextCursor, loadingMore]);8283  const pending = Boolean(key) && (isLoading || debounced !== trimmed);84  const total = countHits(merged);85  return {86    query: debounced,87    parsed,88    searchable,89    data: key ? merged : null,90    loading: pending,91    loadingMore,92    error,93    nextCursor,94    loadMore,95    isEmpty: Boolean(key) && !pending && Boolean(merged) && total === 0,96    total,97  };98}99100/* ------------------------------------------------------------------------------------------------101 * Recent searches (localStorage)102 * ---------------------------------------------------------------------------------------------- */103const RECENT_KEY = "polyllm:recent-searches";104const RECENT_MAX = 8;105106export function useRecentSearches() {107  const [list, setList] = useLocalStorage<string[]>(RECENT_KEY, []);108  const add = React.useCallback(109    (q: string) => {110      const v = q.trim();111      if (v.length < SEARCH_MIN_CHARS) return;112      setList((prev) => [v, ...prev.filter((x) => x.toLowerCase() !== v.toLowerCase())].slice(0, RECENT_MAX));113    },114    [setList],115  );116  const remove = React.useCallback((q: string) => setList((prev) => prev.filter((x) => x !== q)), [setList]);117  const clear = React.useCallback(() => setList([]), [setList]);118  return { list, add, remove, clear };119}120121/** Example queries shown in empty states. */122export const SEARCH_EXAMPLES: { q: string; label: string }[] = [123  { q: "model:claude ", label: "model:claude" },124  { q: "after:7d role:assistant ", label: "after:7d role:assistant" },125  { q: "is:pinned ", label: "is:pinned" },126  { q: "before:2026-01-01 ", label: "before:YYYY-MM-DD" },127  { q: "provider:openai ", label: "provider:openai" },128  { q: "is:shared ", label: "is:shared" },129];130